From d0e68a1a563d3f4cdc6fa8d1e10d832f4b40f5af Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Mon, 11 May 2026 03:27:21 +0330 Subject: [PATCH] UPDATE --- Accsess | 1 + Ai | 1 + BACKEND_PLANTS_AI_FARMDATA_SYNC_PROPOSAL.md | 521 ++ Backend | 1 + Modules/Accsess/README.md | 58 + .../Accsess/config/opa-config.DEVELOP.yaml | 11 + .../Accsess/config/opa-config.default.yaml | 4 + Modules/Accsess/config/opa-config.yaml | 4 + Modules/Accsess/docker-compose.yaml | 43 + Modules/Accsess/logs/.gitkeep | 1 + Modules/Accsess/logs/opa.log | 45 + Modules/Accsess/policies/authz.rego | 68 + .../Accsess/policies/bootstrap-policies.json | 3 + Modules/Accsess/policies/sensor_7_in_1.rego | 48 + .../opa_log_receiver.cpython-314.pyc | Bin 0 -> 3164 bytes Modules/Accsess/scripts/opa_log_receiver.py | 44 + Modules/Ai/.cursor/postman.mdc | 74 + Modules/Ai/.cursor/project.mdc | 96 + Modules/Ai/.cursor/test-rule.mdc | 72 + Modules/Ai/.dockerignore | 16 + Modules/Ai/.env.example | 34 + Modules/Ai/.gitea/workflows/ai.yml | 120 + Modules/Ai/.github/workflows/ai.yml | 120 + Modules/Ai/.gitignore | 64 + Modules/Ai/.gitmodules | 4 + Modules/Ai/API_REFERENCE_FA.md | 1533 ++++ Modules/Ai/API_RELIABILITY_AUDIT_FA.md | 69 + Modules/Ai/APPS_URLS_AUDIT.md | 55 + Modules/Ai/Dockerfile | 39 + Modules/Ai/Dockerfile.Dev | 39 + .../Ai/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md | 688 ++ Modules/Ai/SENSOR_DASHBOARD_API_FA.md | 594 ++ Modules/Ai/Schemas/__init__.py | 41 + Modules/Ai/Schemas/common.py | 32 + .../crop_simulation_current_farm_chart.py | 42 + Modules/Ai/Schemas/crop_simulation_growth.py | 46 + .../Schemas/crop_simulation_growth_status.py | 59 + .../crop_simulation_harvest_prediction.py | 37 + .../crop_simulation_yield_harvest_summary.py | 40 + .../crop_simulation_yield_prediction.py | 41 + Modules/Ai/Schemas/economy_overview.py | 47 + Modules/Ai/Schemas/farm_data_upsert.py | 53 + Modules/Ai/Schemas/fertilization_recommend.py | 125 + Modules/Ai/Schemas/irrigation_list.py | 39 + Modules/Ai/Schemas/irrigation_recommend.py | 49 + Modules/Ai/Schemas/rag_chat.py | 50 + Modules/Ai/Schemas/soile_anomaly_detection.py | 42 + Modules/Ai/Schemas/soile_health_summary.py | 37 + Modules/Ai/Schemas/soile_moisture_heatmap.py | 41 + .../Schemas/weather_water_need_prediction.py | 46 + Modules/Ai/config/__init__.py | 18 + Modules/Ai/config/asgi.py | 7 + Modules/Ai/config/celery.py | 56 + Modules/Ai/config/integration_contract.py | 39 + Modules/Ai/config/knowledge_base/.gitkeep | 0 Modules/Ai/config/knowledge_base/README.md | 3 + .../Ai/config/knowledge_base/chat/README.md | 3 + .../knowledge_base/chat/soil_knowledge.txt | 19 + .../farm_alerts/farm_alerts_knowledge.txt | 14 + .../fertilization/fertilization_knowledge.txt | 142 + .../fertilization_plan_parser_knowledge.txt | 28 + .../irrigation/irrigation_knowledge.txt | 26 + .../irrigation_plan_parser_knowledge.txt | 28 + .../pest_disease/pest_disease_knowledge.txt | 13 + .../soil_anomaly/soil_anomaly_knowledge.txt | 23 + .../config/knowledge_base/soil_knowledge.txt | 19 + .../water_need_prediction_knowledge.txt | 17 + Modules/Ai/config/openapi.py | 103 + Modules/Ai/config/rag_config.yaml | 239 + Modules/Ai/config/settings.py | 250 + Modules/Ai/config/settings_test.py | 15 + Modules/Ai/config/test_urls.py | 15 + Modules/Ai/config/tone.txt | 7 + Modules/Ai/config/tones/chat_tone.txt | 152 + Modules/Ai/config/tones/farm_alerts_tone.txt | 65 + .../tones/fertilization_plan_parser_tone.txt | 93 + .../Ai/config/tones/fertilization_tone.txt | 106 + .../tones/irrigation_plan_parser_tone.txt | 87 + Modules/Ai/config/tones/irrigation_tone.txt | 75 + Modules/Ai/config/tones/pest_disease_tone.txt | 49 + Modules/Ai/config/tones/soil_anomaly_tone.txt | 22 + .../tones/water_need_prediction_tone.txt | 21 + .../Ai/config/tones/yield_harvest_tone.txt | 39 + Modules/Ai/config/urls.py | 23 + Modules/Ai/config/user_info/.gitkeep | 0 Modules/Ai/config/wsgi.py | 7 + Modules/Ai/constraints.txt | 2 + .../crop_simulation/SERVICES_INTEGRATION.md | 822 ++ Modules/Ai/crop_simulation/__init__.py | 1 + Modules/Ai/crop_simulation/apps.py | 54 + .../Ai/crop_simulation/growth_simulation.py | 802 ++ .../Ai/crop_simulation/harvest_prediction.py | 172 + .../migrations/0001_initial.py | 125 + ...002_alter_simulationscenario_model_name.py | 15 + .../Ai/crop_simulation/migrations/__init__.py | 1 + Modules/Ai/crop_simulation/models.py | 84 + .../recommendation_optimizer.py | 801 ++ Modules/Ai/crop_simulation/serializers.py | 184 + Modules/Ai/crop_simulation/services.py | 1359 +++ Modules/Ai/crop_simulation/tasks.py | 13 + .../test_growth_simulation_api.py | 495 + Modules/Ai/crop_simulation/test_single_run.py | 63 + .../test_single_run_with_recommendations.py | 76 + Modules/Ai/crop_simulation/tests.py | 368 + Modules/Ai/crop_simulation/urls.py | 24 + Modules/Ai/crop_simulation/views.py | 571 ++ Modules/Ai/crop_simulation/water_stress.py | 143 + .../crop_simulation/yield_harvest_summary.py | 1024 +++ .../Ai/crop_simulation/yield_prediction.py | 61 + Modules/Ai/docker-compose-prod.yaml | 107 + Modules/Ai/docker-compose.yaml | 123 + .../Ai/docs/crop_simulation_api_reference.md | 1183 +++ Modules/Ai/docs/farm_alerts_tracker_api.md | 180 + .../farm_alerts_tracker_response_fields.md | 492 + ...rigation_fertilization_plan_parser_apis.md | 619 ++ .../location_and_farm_data_apps_explained.md | 512 ++ .../docs/location_data_current_structure.md | 371 + .../Ai/docs/location_data_current_workflow.md | 685 ++ .../docs/location_data_full_architecture.md | 972 ++ Modules/Ai/docs/pcse_api_list.md | 290 + .../Ai/docs/updated_pcse_apis_reference.md | 743 ++ .../docs/yield_harvest_pcse_rag_api_plan.md | 746 ++ Modules/Ai/economy/__init__.py | 1 + Modules/Ai/economy/apps.py | 18 + Modules/Ai/economy/serializers.py | 26 + Modules/Ai/economy/services.py | 10 + .../Ai/economy/test_economic_overview_api.py | 20 + Modules/Ai/economy/urls.py | 8 + Modules/Ai/economy/views.py | 76 + Modules/Ai/entrypoint.sh | 51 + Modules/Ai/farm_alerts/__init__.py | 0 Modules/Ai/farm_alerts/alerts_tracker.py | 562 ++ Modules/Ai/farm_alerts/apps.py | 7 + .../Ai/farm_alerts/migrations/0001_initial.py | 38 + Modules/Ai/farm_alerts/migrations/__init__.py | 0 Modules/Ai/farm_alerts/models.py | 45 + Modules/Ai/farm_alerts/serializers.py | 51 + Modules/Ai/farm_alerts/services.py | 436 + Modules/Ai/farm_alerts/urls.py | 8 + Modules/Ai/farm_alerts/views.py | 76 + Modules/Ai/farm_data/__init__.py | 0 Modules/Ai/farm_data/admin.py | 48 + Modules/Ai/farm_data/apps.py | 8 + Modules/Ai/farm_data/context.py | 34 + Modules/Ai/farm_data/management/__init__.py | 0 .../farm_data/management/commands/__init__.py | 0 .../management/commands/seed_farm_data.py | 74 + .../commands/seed_sensor_parameters.py | 70 + .../Ai/farm_data/migrations/0001_initial.py | 88 + .../0002_seed_initial_parameters.py | 13 + .../migrations/0003_sensordata_plants.py | 19 + .../0004_alter_sensordata_location.py | 20 + .../0005_delete_sensordatahistory.py | 14 + ...6_sensor_payload_and_dynamic_parameters.py | 139 + .../0007_rename_uuid_sensor_to_farm_uuid.py | 16 + ...0008_rename_location_to_center_location.py | 29 + ...0009_add_weather_forecast_to_sensordata.py | 45 + .../0010_rename_tables_to_farm_data.py | 44 + .../0011_sensordata_irrigation_method.py | 26 + ...2_plant_catalog_snapshot_and_assignment.py | 70 + Modules/Ai/farm_data/migrations/__init__.py | 0 Modules/Ai/farm_data/models.py | 337 + Modules/Ai/farm_data/postman/farm_data.json | 53 + Modules/Ai/farm_data/serializers.py | 243 + Modules/Ai/farm_data/services.py | 761 ++ Modules/Ai/farm_data/tests/__init__.py | 1 + .../farm_data/tests/test_farm_detail_api.py | 402 + Modules/Ai/farm_data/urls.py | 26 + Modules/Ai/farm_data/views.py | 481 + ...FERTILIZATION_RECOMMENDATION_API_FIELDS.md | 341 + Modules/Ai/fertilization/__init__.py | 1 + Modules/Ai/fertilization/apps.py | 95 + Modules/Ai/fertilization/serializers.py | 151 + Modules/Ai/fertilization/urls.py | 8 + Modules/Ai/fertilization/views.py | 234 + Modules/Ai/integration_tests/README.md | 19 + Modules/Ai/integration_tests/__init__.py | 1 + Modules/Ai/integration_tests/base.py | 181 + .../test_management_api_flow.py | 198 + .../test_reporting_and_ai_api_flow.py | 567 ++ .../IRRIGATION_RECOMMENDATION_API_FIELDS.md | 363 + Modules/Ai/irrigation/__init__.py | 1 + Modules/Ai/irrigation/admin.py | 19 + Modules/Ai/irrigation/apps.py | 69 + Modules/Ai/irrigation/evapotranspiration.py | 194 + Modules/Ai/irrigation/indicators.py | 30 + Modules/Ai/irrigation/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../commands/seed_irrigation_methods.py | 100 + .../Ai/irrigation/migrations/0001_initial.py | 36 + Modules/Ai/irrigation/migrations/__init__.py | 1 + Modules/Ai/irrigation/models.py | 63 + Modules/Ai/irrigation/serializers.py | 119 + .../test_irrigation_recommend_api.py | 64 + .../Ai/irrigation/test_water_stress_api.py | 94 + Modules/Ai/irrigation/urls.py | 17 + Modules/Ai/irrigation/views.py | 494 + .../Ai/location_data/LOCATION_DATA_FLOW.md | 378 + Modules/Ai/location_data/__init__.py | 0 Modules/Ai/location_data/admin.py | 136 + Modules/Ai/location_data/apps.py | 18 + Modules/Ai/location_data/block_subdivision.py | 401 + .../location_data/data_driven_subdivision.py | 421 + Modules/Ai/location_data/grid_analysis.py | 327 + .../Ai/location_data/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/rename_soil_data_label.py | 32 + .../commands/repair_location_tables.py | 35 + .../location_data/migrations/0001_initial.py | 34 + .../migrations/0002_soildepthdata_refactor.py | 77 + .../0002_soillocation_ideal_sensor_profile.py | 23 + .../migrations/0003_rename_app_label.py | 17 + .../0004_soillocation_farm_boundary.py | 23 + .../migrations/0005_merge_20260327_0840.py | 14 + ...emove_soillocation_ideal_sensor_profile.py | 15 + .../migrations/0007_ndviobservation.py | 46 + .../0008_soillocation_block_layout.py | 45 + .../migrations/0009_blocksubdivision.py | 38 + .../0010_blocksubdivision_elbow_plot.py | 21 + .../migrations/0011_remote_sensing_models.py | 110 + .../0012_remote_sensing_subdivision_models.py | 65 + .../migrations/0013_remove_soildepthdata.py | 14 + .../Ai/location_data/migrations/__init__.py | 0 Modules/Ai/location_data/models.py | 523 ++ Modules/Ai/location_data/ndvi.py | 92 + Modules/Ai/location_data/openeo_service.py | 476 + .../Ai/location_data/postman/soil_data.json | 93 + Modules/Ai/location_data/remote_sensing.py | 155 + .../Ai/location_data/satellite_snapshot.py | 116 + Modules/Ai/location_data/serializers.py | 340 + Modules/Ai/location_data/tasks.py | 615 ++ .../location_data/test_block_subdivision.py | 44 + .../Ai/location_data/test_grid_analysis.py | 114 + .../Ai/location_data/test_ndvi_health_api.py | 66 + .../Ai/location_data/test_openeo_service.py | 66 + .../location_data/test_remote_sensing_api.py | 265 + Modules/Ai/location_data/test_soil_api.py | 126 + Modules/Ai/location_data/urls.py | 17 + Modules/Ai/location_data/views.py | 938 ++ Modules/Ai/manage.py | 22 + Modules/Ai/pest_disease/__init__.py | 0 Modules/Ai/pest_disease/apps.py | 7 + Modules/Ai/pest_disease/serializers.py | 31 + Modules/Ai/pest_disease/urls.py | 9 + Modules/Ai/pest_disease/views.py | 165 + Modules/Ai/plant/PLANT_NAMES_API.md | 74 + Modules/Ai/plant/__init__.py | 1 + Modules/Ai/plant/admin.py | 19 + Modules/Ai/plant/apps.py | 109 + Modules/Ai/plant/gdd.py | 107 + Modules/Ai/plant/management/__init__.py | 1 + .../Ai/plant/management/commands/__init__.py | 1 + .../plant/management/commands/seed_plants.py | 109 + Modules/Ai/plant/migrations/0001_initial.py | 36 + .../migrations/0002_plant_health_profile.py | 23 + .../0003_plant_irrigation_profile.py | 24 + .../migrations/0004_plant_growth_profile.py | 24 + .../migrations/0005_plant_growth_stage.py | 20 + .../Ai/plant/migrations/0006_plant_icon.py | 20 + Modules/Ai/plant/migrations/__init__.py | 1 + Modules/Ai/plant/models.py | 101 + Modules/Ai/plant/serializers.py | 64 + Modules/Ai/plant/services.py | 34 + Modules/Ai/plant/urls.py | 15 + Modules/Ai/plant/views.py | 364 + Modules/Ai/psce_doc.txt | 8105 +++++++++++++++++ Modules/Ai/rag/RAG_LOGIC_SIMPLE.md | 527 ++ Modules/Ai/rag/README.md | 393 + Modules/Ai/rag/__init__.py | 5 + Modules/Ai/rag/api_provider.py | 92 + Modules/Ai/rag/apps.py | 7 + Modules/Ai/rag/chat.py | 434 + Modules/Ai/rag/chunker.py | 65 + Modules/Ai/rag/client.py | 19 + Modules/Ai/rag/config.py | 194 + Modules/Ai/rag/embedding.py | 91 + Modules/Ai/rag/failure_contract.py | 55 + Modules/Ai/rag/ingest.py | 187 + Modules/Ai/rag/management/__init__.py | 0 .../Ai/rag/management/commands/__init__.py | 0 .../Ai/rag/management/commands/rag_ingest.py | 30 + Modules/Ai/rag/migrations/0001_initial.py | 33 + Modules/Ai/rag/migrations/__init__.py | 1 + Modules/Ai/rag/models.py | 62 + Modules/Ai/rag/observability.py | 129 + Modules/Ai/rag/retrieve.py | 134 + Modules/Ai/rag/services/__init__.py | 24 + Modules/Ai/rag/services/fertilization.py | 738 ++ .../rag/services/fertilization_plan_parser.py | 398 + Modules/Ai/rag/services/irrigation.py | 539 ++ .../Ai/rag/services/irrigation_plan_parser.py | 397 + Modules/Ai/rag/services/pest_disease.py | 470 + Modules/Ai/rag/services/soil_anomaly.py | 214 + .../Ai/rag/services/water_need_prediction.py | 211 + Modules/Ai/rag/services/yield_harvest.py | 263 + Modules/Ai/rag/tasks.py | 77 + Modules/Ai/rag/tests/test_chat_context.py | 71 + .../Ai/rag/tests/test_failure_contracts.py | 88 + Modules/Ai/rag/tests/test_observability.py | 51 + .../rag/tests/test_recommendation_services.py | 387 + Modules/Ai/rag/urls.py | 9 + Modules/Ai/rag/user_data.py | 205 + Modules/Ai/rag/vector_store.py | 172 + Modules/Ai/rag/views.py | 205 + Modules/Ai/requirements.txt | 42 + Modules/Ai/scripts/fix_farm_data_tables.sh | 12 + Modules/Ai/scripts/generate_mock_data.py | 798 ++ Modules/Ai/soile/__init__.py | 1 + Modules/Ai/soile/anomaly_detection.py | 262 + Modules/Ai/soile/apps.py | 36 + Modules/Ai/soile/health_summary.py | 147 + Modules/Ai/soile/serializers.py | 44 + Modules/Ai/soile/services.py | 489 + .../soile/test_soil_moisture_heatmap_api.py | 282 + Modules/Ai/soile/urls.py | 10 + Modules/Ai/soile/views.py | 197 + Modules/Ai/weather/__init__.py | 1 + Modules/Ai/weather/adapters.py | 281 + Modules/Ai/weather/admin.py | 24 + Modules/Ai/weather/apps.py | 36 + Modules/Ai/weather/farm_weather.py | 83 + Modules/Ai/weather/management/__init__.py | 1 + .../weather/management/commands/__init__.py | 1 + .../management/commands/seed_weather_data.py | 89 + .../commands/seed_weather_parameters.py | 42 + Modules/Ai/weather/migrations/0001_initial.py | 184 + .../0002_seed_weather_parameters.py | 42 + .../migrations/0003_seed_weather_forecasts.py | 137 + Modules/Ai/weather/migrations/__init__.py | 1 + Modules/Ai/weather/models.py | 107 + Modules/Ai/weather/serializers.py | 35 + Modules/Ai/weather/services.py | 184 + Modules/Ai/weather/tasks.py | 34 + Modules/Ai/weather/test_adapters.py | 103 + Modules/Ai/weather/test_farm_weather_api.py | 152 + Modules/Ai/weather/urls.py | 9 + Modules/Ai/weather/views.py | 151 + Modules/Ai/weather/water_need_prediction.py | 81 + Modules/Backend/.cursor/postman.mdc | 74 + Modules/Backend/.cursor/project.mdc | 96 + Modules/Backend/.cursor/test-rule.mdc | 72 + Modules/Backend/.dockerignore | 16 + Modules/Backend/.env.example | 45 + Modules/Backend/.gitea/workflows/backend.yml | 120 + Modules/Backend/.github/workflows/backend.yml | 72 + Modules/Backend/.gitignore | 59 + Modules/Backend/.gitmodules | 4 + Modules/Backend/AGENTS.md | 38 + .../Backend/AI_INTEGRATION_FLOW_CONTRACT.md | 26 + Modules/Backend/AI_ROUTE_CONNECTION_AUDIT.md | 100 + .../API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md | 68 + Modules/Backend/Dockerfile | 41 + Modules/Backend/Dockerfile.Dev | 44 + Modules/Backend/access_control/__init__.py | 0 Modules/Backend/access_control/apps.py | 6 + Modules/Backend/access_control/catalog.py | 1 + Modules/Backend/access_control/middleware.py | 96 + .../access_control/migrations/0001_initial.py | 69 + .../0002_link_subscription_plan_to_farm.py | 25 + .../0003_seed_default_access_rules.py | 9 + .../0004_enable_default_feature_access.py | 9 + .../0005_backfill_farm_subscription_plans.py | 10 + .../access_control/migrations/__init__.py | 0 Modules/Backend/access_control/models.py | 104 + Modules/Backend/access_control/permissions.py | 50 + Modules/Backend/access_control/serializers.py | 17 + Modules/Backend/access_control/services.py | 430 + Modules/Backend/access_control/tests.py | 142 + Modules/Backend/access_control/urls.py | 7 + Modules/Backend/access_control/views.py | 74 + Modules/Backend/account/__init__.py | 0 Modules/Backend/account/apps.py | 6 + Modules/Backend/account/backends.py | 25 + .../Backend/account/management/__init__.py | 1 + .../account/management/commands/__init__.py | 1 + .../management/commands/seed_admin_user.py | 24 + .../account/migrations/0001_initial.py | 134 + ...02_alter_user_managers_alter_user_email.py | 25 + .../Backend/account/migrations/__init__.py | 0 Modules/Backend/account/models.py | 37 + Modules/Backend/account/postman/account.json | 146 + Modules/Backend/account/seeds.py | 44 + Modules/Backend/account/serializers.py | 16 + Modules/Backend/account/urls.py | 9 + Modules/Backend/account/views.py | 160 + .../api_changes_last_6_commits_combined.md | 232 + Modules/Backend/auth/__init__.py | 1 + Modules/Backend/auth/apps.py | 7 + Modules/Backend/auth/postman/postman.json | 59 + Modules/Backend/auth/serializers.py | 49 + Modules/Backend/auth/sms_service.py | 59 + Modules/Backend/auth/urls.py | 10 + Modules/Backend/auth/views.py | 208 + Modules/Backend/celerybeat-schedule | Bin 0 -> 16384 bytes Modules/Backend/config/__init__.py | 3 + Modules/Backend/config/asgi.py | 7 + Modules/Backend/config/celery.py | 9 + Modules/Backend/config/failure_contract.py | 53 + Modules/Backend/config/feature.json | 18 + .../Backend/config/integration_contract.py | 45 + Modules/Backend/config/observability.py | 129 + Modules/Backend/config/settings.py | 294 + Modules/Backend/config/swagger.py | 55 + Modules/Backend/config/urls.py | 44 + Modules/Backend/config/wsgi.py | 7 + Modules/Backend/crop_health/__init__.py | 1 + Modules/Backend/crop_health/apps.py | 7 + Modules/Backend/crop_health/mock_data.py | 27 + Modules/Backend/crop_health/models.py | 2 + Modules/Backend/crop_health/serializers.py | 38 + Modules/Backend/crop_health/services.py | 10 + Modules/Backend/crop_health/tests.py | 110 + Modules/Backend/crop_health/urls.py | 8 + Modules/Backend/crop_health/views.py | 82 + .../crop_zoning/CROP_ZONING_CODE_LOGIC.md | 883 ++ .../crop_zoning/CROP_ZONING_FRONTEND_API.md | 285 + ...CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md | 282 + Modules/Backend/crop_zoning/__init__.py | 0 Modules/Backend/crop_zoning/apps.py | 10 + Modules/Backend/crop_zoning/defaults.py | 27 + .../crop_zoning/migrations/0001_initial.py | 54 + .../0002_crop_zoning_mock_schema.py | 99 + .../0003_zone_processing_and_analysis.py | 49 + .../migrations/0004_croparea_farm.py | 23 + .../crop_zoning/migrations/__init__.py | 0 Modules/Backend/crop_zoning/mock_data.py | 354 + Modules/Backend/crop_zoning/models.py | 224 + .../crop_zoning/postman/crop_zoning.json | 1 + Modules/Backend/crop_zoning/services.py | 1213 +++ Modules/Backend/crop_zoning/tasks.py | 17 + Modules/Backend/crop_zoning/tests.py | 419 + Modules/Backend/crop_zoning/urls.py | 43 + Modules/Backend/crop_zoning/views.py | 215 + Modules/Backend/dashboard/__init__.py | 0 Modules/Backend/dashboard/apps.py | 7 + Modules/Backend/dashboard/defaults.py | 42 + .../dashboard/migrations/0001_initial.py | 36 + ...002_alter_farmdashboardconfig_row_order.py | 18 + .../Backend/dashboard/migrations/__init__.py | 0 Modules/Backend/dashboard/mock_data.py | 21 + Modules/Backend/dashboard/models.py | 23 + .../dashboard/postman/farm_dashboard.json | 1 + Modules/Backend/dashboard/serializers.py | 61 + Modules/Backend/dashboard/services.py | 185 + Modules/Backend/dashboard/templates.py | 318 + Modules/Backend/dashboard/tests.py | 186 + Modules/Backend/dashboard/urls.py | 8 + Modules/Backend/dashboard/urls_config.py | 7 + Modules/Backend/dashboard/views.py | 132 + Modules/Backend/device_hub/API_GUIDE.md | 734 ++ Modules/Backend/device_hub/__init__.py | 1 + Modules/Backend/device_hub/apps.py | 8 + Modules/Backend/device_hub/authentication.py | 18 + Modules/Backend/device_hub/catalog_seed.py | 52 + Modules/Backend/device_hub/comparison_urls.py | 9 + .../Backend/device_hub/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../management/commands/seed_sensor_7_in_1.py | 23 + .../commands/seed_sensor_catalog.py | 22 + .../device_hub/migrations/0001_initial.py | 108 + .../migrations/0002_absorb_sensor_7_in_1.py | 9 + .../0003_absorb_sensor_external_api.py | 9 + .../migrations/0004_absorb_sensor_catalog.py | 35 + .../0005_rename_farm_sensor_to_farm_device.py | 19 + ...evicecatalog_and_add_communication_type.py | 42 + .../0007_devicecatalog_dynamic_fields.py | 36 + .../0008_farmdevice_device_catalogs.py | 51 + .../0009_sync_devicecatalog_schema.py | 47 + .../Backend/device_hub/migrations/__init__.py | 1 + Modules/Backend/device_hub/mock_data.py | 57 + Modules/Backend/device_hub/models.py | 106 + Modules/Backend/device_hub/seeds.py | 60 + .../Backend/device_hub/sensor_7_in_1_urls.py | 9 + .../Backend/device_hub/sensor_catalog_urls.py | 7 + .../device_hub/sensor_external_api_urls.py | 9 + .../Backend/device_hub/sensor_serializers.py | 111 + Modules/Backend/device_hub/serializers.py | 196 + Modules/Backend/device_hub/services.py | 1199 +++ Modules/Backend/device_hub/templates.py | 23 + Modules/Backend/device_hub/tests.py | 170 + Modules/Backend/device_hub/urls.py | 20 + Modules/Backend/device_hub/views.py | 329 + Modules/Backend/docker-compose-prod.yaml | 163 + Modules/Backend/docker-compose.yaml | 166 + .../Backend/docs/dashboard_api_reference.md | 240 + .../docs/dashboard_card_service_map.md | 80 + .../device_catalog_dynamic_architecture.md | 860 ++ .../fertilization_recommendation_frontend.md | 330 + ...rigation_fertilization_plan_parser_apis.md | 619 ++ ...ase_risk_summary_frontend_api_reference.md | 247 + .../recommend_task_status_frontend_backend.md | 267 + .../docs/sensor_frontend_api_reference.md | 1062 +++ .../docs/soil_frontend_api_reference.md | 381 + .../water_weather_frontend_api_reference.md | 381 + .../docs/yield_harvest_ai_integration.md | 941 ++ .../yield_harvest_prediction_api_changes.md | 269 + Modules/Backend/economic_overview/__init__.py | 0 Modules/Backend/economic_overview/apps.py | 7 + Modules/Backend/economic_overview/defaults.py | 8 + .../migrations/0001_initial.py | 28 + .../economic_overview/migrations/__init__.py | 0 .../Backend/economic_overview/mock_data.py | 37 + Modules/Backend/economic_overview/models.py | 28 + .../Backend/economic_overview/serializers.py | 26 + Modules/Backend/economic_overview/services.py | 27 + Modules/Backend/economic_overview/tests.py | 97 + Modules/Backend/economic_overview/urls.py | 7 + Modules/Backend/economic_overview/views.py | 143 + Modules/Backend/entrypoint.sh | 15 + .../Backend/external_api_adapter/README.md | 33 + .../Backend/external_api_adapter/__init__.py | 3 + .../Backend/external_api_adapter/adapter.py | 176 + Modules/Backend/external_api_adapter/apps.py | 7 + .../external_api_adapter/exceptions.py | 18 + .../ai/dashboard-data/generate/post_202.json | 8 + .../ai/dashboard-data/generate/post_400.json | 5 + .../status/get_200_failure.json | 9 + .../status/get_200_pending.json | 9 + .../status/get_200_progress.json | 14 + .../status/get_200_success.json | 611 ++ .../ai/fertilization/recommend/post_202.json | 8 + .../ai/fertilization/recommend/post_400.json | 9 + .../fertilization/status/get_200_failure.json | 9 + .../fertilization/status/get_200_pending.json | 9 + .../status/get_200_progress.json | 11 + .../fertilization/status/get_200_success.json | 19 + .../external_api_adapter/json/ai/index.json | 892 ++ .../irrigation/method-detail/delete_200.json | 5 + .../irrigation/method-detail/delete_404.json | 5 + .../ai/irrigation/method-detail/get_200.json | 18 + .../ai/irrigation/method-detail/get_404.json | 5 + .../irrigation/method-detail/patch_200.json | 18 + .../irrigation/method-detail/patch_400.json | 9 + .../irrigation/method-detail/patch_404.json | 5 + .../ai/irrigation/method-detail/put_200.json | 18 + .../ai/irrigation/method-detail/put_400.json | 9 + .../ai/irrigation/method-detail/put_404.json | 5 + .../json/ai/irrigation/methods/get_200.json | 20 + .../json/ai/irrigation/methods/post_201.json | 18 + .../json/ai/irrigation/methods/post_400.json | 9 + .../ai/irrigation/recommend/post_202.json | 8 + .../ai/irrigation/recommend/post_400.json | 9 + .../recommend/status/get_200_failure.json | 9 + .../recommend/status/get_200_pending.json | 9 + .../recommend/status/get_200_progress.json | 11 + .../recommend/status/get_200_success.json | 37 + .../risk-summary/get_200_success.json | 47 + .../json/ai/plant/create-post_201.json | 18 + .../json/ai/plant/create-post_400.json | 9 + .../json/ai/plant/detail-delete_200.json | 5 + .../json/ai/plant/detail-delete_404.json | 5 + .../json/ai/plant/detail-get_200.json | 18 + .../json/ai/plant/detail-get_404.json | 5 + .../json/ai/plant/detail-patch_200.json | 18 + .../json/ai/plant/detail-patch_400.json | 9 + .../json/ai/plant/detail-patch_404.json | 5 + .../json/ai/plant/detail-put_200.json | 18 + .../json/ai/plant/detail-put_400.json | 9 + .../json/ai/plant/detail-put_404.json | 5 + .../json/ai/plant/fetch-info-post_200.json | 18 + .../json/ai/plant/fetch-info-post_400.json | 5 + .../json/ai/plant/fetch-info-post_503.json | 5 + .../json/ai/plant/list-get_200.json | 20 + .../json/ai/predict/get_200_success.json | 9 + .../json/ai/rag/chat-post_200_stream.json | 29 + .../ai/rag/chat-post_400_invalid_service.json | 4 + .../ai/rag/chat-post_400_missing_query.json | 4 + .../ai/rag/chat-post_400_missing_user.json | 4 + .../json/ai/rag/chat/generate/post_202.json | 9 + .../json/ai/rag/fertilization/post_202.json | 8 + .../json/ai/rag/fertilization/post_400.json | 5 + .../fertilization/status/get_200_failure.json | 9 + .../fertilization/status/get_200_pending.json | 9 + .../status/get_200_progress.json | 11 + .../fertilization/status/get_200_success.json | 19 + .../json/ai/rag/irrigation/post_202.json | 8 + .../json/ai/rag/irrigation/post_400.json | 5 + .../irrigation/status/get_200_failure.json | 9 + .../irrigation/status/get_200_pending.json | 9 + .../irrigation/status/get_200_progress.json | 11 + .../irrigation/status/get_200_success.json | 37 + .../ai/sensor-data/parameters-post_201.json | 12 + .../ai/sensor-data/parameters-post_400.json | 9 + .../json/ai/sensor-data/update-patch_200.json | 20 + .../json/ai/sensor-data/update-patch_400.json | 9 + .../json/ai/sensor-data/update-patch_404.json | 5 + .../json/ai/sensor-data/update-put_200.json | 20 + .../json/ai/sensor-data/update-put_400.json | 9 + .../json/ai/sensor-data/update-put_404.json | 5 + .../json/ai/soil-data/get_200_database.json | 63 + .../json/ai/soil-data/get_202_queued.json | 11 + .../json/ai/soil-data/get_400.json | 12 + .../json/ai/soil-data/post_200_database.json | 63 + .../json/ai/soil-data/post_202_queued.json | 11 + .../json/ai/soil-data/post_400.json | 9 + .../ai/soil-data/status/get_200_failure.json | 9 + .../ai/soil-data/status/get_200_pending.json | 9 + .../ai/soil-data/status/get_200_progress.json | 12 + .../ai/soil-data/status/get_200_success.json | 67 + .../json/ai/tasks/post_200.json | 7 + .../json/ai/tasks/status/get_200_failure.json | 9 + .../json/ai/tasks/status/get_200_pending.json | 9 + .../ai/tasks/status/get_200_progress.json | 13 + .../json/ai/tasks/status/get_200_success.json | 11 + .../card/get_200_success.json | 17 + .../summary/get_200_success.json | 51 + .../json/sensor_hub/.gitkeep | 1 + .../external_api_adapter/mock_loader.py | 163 + .../Backend/external_api_adapter/services.py | 14 + Modules/Backend/external_api_adapter/tests.py | 60 + Modules/Backend/farm_ai_assistant/__init__.py | 0 .../chat_api_changes_last_6_commits.md | 105 + Modules/Backend/farm_ai_assistant/defaults.py | 9 + .../migrations/0001_initial.py | 49 + .../0002_conversation_farm_message_farm.py | 34 + .../farm_ai_assistant/migrations/__init__.py | 0 .../Backend/farm_ai_assistant/mock_data.py | 87 + Modules/Backend/farm_ai_assistant/models.py | 68 + .../postman/farm_ai_assistant.json | 120 + .../Backend/farm_ai_assistant/serializers.py | 97 + Modules/Backend/farm_ai_assistant/tests.py | 412 + Modules/Backend/farm_ai_assistant/urls.py | 17 + Modules/Backend/farm_ai_assistant/views.py | 615 ++ .../farm_alerts/TRACKER_API_FRONTEND.md | 436 + Modules/Backend/farm_alerts/__init__.py | 0 Modules/Backend/farm_alerts/apps.py | 7 + Modules/Backend/farm_alerts/defaults.py | 29 + .../farm_alerts/migrations/0001_initial.py | 61 + ...lter_anomalydetection_severity_and_more.py | 23 + .../0003_farmalert_tracker_fields.py | 43 + .../0004_farmalerttrackersnapshot.py | 48 + .../farm_alerts/migrations/__init__.py | 0 Modules/Backend/farm_alerts/mock_data.py | 101 + Modules/Backend/farm_alerts/models.py | 98 + Modules/Backend/farm_alerts/serializers.py | 99 + Modules/Backend/farm_alerts/services.py | 480 + Modules/Backend/farm_alerts/tasks.py | 21 + Modules/Backend/farm_alerts/tests.py | 211 + Modules/Backend/farm_alerts/urls.py | 7 + Modules/Backend/farm_alerts/views.py | 83 + Modules/Backend/farm_hub/API_REFERENCE_FA.md | 918 ++ Modules/Backend/farm_hub/__init__.py | 1 + Modules/Backend/farm_hub/apps.py | 6 + Modules/Backend/farm_hub/catalog.py | 23 + .../Backend/farm_hub/management/__init__.py | 1 + .../farm_hub/management/commands/__init__.py | 1 + .../management/commands/seed_admin_farm.py | 20 + .../management/commands/seed_farm_catalog.py | 29 + .../farm_hub/migrations/0001_initial.py | 125 + .../migrations/0002_seed_default_catalog.py | 34 + ..._farmsensor_catalog_and_physical_device.py | 31 + ...ove_customization_add_current_crop_area.py | 33 + ...05_product_profiles_and_plant_migration.py | 163 + .../0006_seed_expanded_product_catalog.py | 55 + .../0007_farmhub_subscription_plan.py | 25 + .../0008_product_plant_selector_fields.py | 25 + .../0009_farmhub_irrigation_method_fields.py | 20 + .../0010_move_farmsensor_to_device_hub.py | 17 + .../Backend/farm_hub/migrations/__init__.py | 0 Modules/Backend/farm_hub/models.py | 124 + .../Backend/farm_hub/postman/farm_hub.json | 117 + Modules/Backend/farm_hub/seeds.py | 103 + Modules/Backend/farm_hub/serializers.py | 325 + Modules/Backend/farm_hub/services.py | 194 + Modules/Backend/farm_hub/tests.py | 491 + Modules/Backend/farm_hub/urls.py | 19 + Modules/Backend/farm_hub/views.py | 217 + .../farmer_calendar/FARMER_CALENDAR_API.md | 392 + Modules/Backend/farmer_calendar/__init__.py | 309 + Modules/Backend/farmer_calendar/apps.py | 7 + Modules/Backend/farmer_calendar/enums.py | 41 + .../migrations/0001_initial.py | 67 + .../0002_add_zone_and_todo_fields.py | 209 + .../farmer_calendar/migrations/__init__.py | 0 Modules/Backend/farmer_calendar/models.py | 84 + .../Backend/farmer_calendar/serializers.py | 119 + Modules/Backend/farmer_calendar/tests.py | 167 + Modules/Backend/farmer_calendar/urls.py | 9 + Modules/Backend/farmer_calendar/views.py | 164 + .../Backend/farmer_todos/FARMER_TODOS_API.md | 507 ++ Modules/Backend/farmer_todos/__init__.py | 0 Modules/Backend/farmer_todos/apps.py | 7 + .../farmer_todos/migrations/0001_initial.py | 85 + .../0002_merge_todos_into_calendar.py | 104 + .../farmer_todos/migrations/__init__.py | 0 Modules/Backend/farmer_todos/models.py | 10 + Modules/Backend/farmer_todos/serializers.py | 178 + Modules/Backend/farmer_todos/tests.py | 238 + Modules/Backend/farmer_todos/urls.py | 17 + Modules/Backend/farmer_todos/views.py | 242 + .../fertilization/FERTILIZATION_PLAN_APIS.md | 235 + Modules/Backend/fertilization/__init__.py | 1 + Modules/Backend/fertilization/apps.py | 8 + Modules/Backend/fertilization/defaults.py | 35 + .../fertilization/migrations/0001_initial.py | 41 + .../0002_recommendation_status_lifecycle.py | 37 + .../migrations/0003_fertilizationplan.py | 44 + ...0004_fertilizationplan_default_inactive.py | 16 + .../fertilization/migrations/__init__.py | 0 Modules/Backend/fertilization/mock_data.py | 44 + Modules/Backend/fertilization/models.py | 92 + .../postman/fertilization_recommendation.json | 1 + Modules/Backend/fertilization/serializers.py | 205 + Modules/Backend/fertilization/services.py | 100 + Modules/Backend/fertilization/tests.py | 553 ++ Modules/Backend/fertilization/urls.py | 23 + Modules/Backend/fertilization/views.py | 708 ++ .../Backend/irrigation/API_REFERENCE_FA.md | 492 + .../irrigation/IRRIGATION_PLAN_APIS.md | 232 + Modules/Backend/irrigation/__init__.py | 1 + Modules/Backend/irrigation/apps.py | 8 + Modules/Backend/irrigation/defaults.py | 28 + .../irrigation/migrations/0001_initial.py | 40 + ..._recommendation_status_and_growth_stage.py | 30 + .../migrations/0003_irrigationplan.py | 44 + .../0004_irrigationplan_default_inactive.py | 16 + .../Backend/irrigation/migrations/__init__.py | 0 Modules/Backend/irrigation/mock_data.py | 44 + Modules/Backend/irrigation/models.py | 94 + .../postman/irrigation_recommendation.json | 1 + Modules/Backend/irrigation/serializers.py | 155 + Modules/Backend/irrigation/services.py | 325 + Modules/Backend/irrigation/tests.py | 734 ++ Modules/Backend/irrigation/urls.py | 27 + Modules/Backend/irrigation/views.py | 667 ++ Modules/Backend/manage.py | 22 + .../notifications/NOTIFICATION_API_CHANGES.md | 80 + Modules/Backend/notifications/__init__.py | 0 Modules/Backend/notifications/apps.py | 7 + .../notifications/migrations/0001_initial.py | 41 + .../0002_farmnotification_tracker_fields.py | 44 + .../notifications/migrations/__init__.py | 1 + Modules/Backend/notifications/models.py | 31 + Modules/Backend/notifications/serializers.py | 30 + Modules/Backend/notifications/services.py | 112 + Modules/Backend/notifications/tests.py | 353 + Modules/Backend/notifications/urls.py | 9 + Modules/Backend/notifications/views.py | 160 + Modules/Backend/pest_detection/__init__.py | 1 + Modules/Backend/pest_detection/apps.py | 7 + Modules/Backend/pest_detection/mock_data.py | 54 + .../pest_detection/pest_disease_urls.py | 9 + .../postman/pest_detection.json | 1 + Modules/Backend/pest_detection/serializers.py | 82 + Modules/Backend/pest_detection/services.py | 7 + Modules/Backend/pest_detection/tests.py | 405 + Modules/Backend/pest_detection/urls.py | 1 + Modules/Backend/pest_detection/views.py | 293 + Modules/Backend/plants/__init__.py | 0 Modules/Backend/plants/apps.py | 7 + Modules/Backend/plants/migrations/__init__.py | 0 Modules/Backend/plants/models.py | 3 + Modules/Backend/plants/serializers.py | 38 + Modules/Backend/plants/services.py | 152 + Modules/Backend/plants/tests.py | 121 + Modules/Backend/plants/urls.py | 10 + Modules/Backend/plants/views.py | 151 + Modules/Backend/requirements.txt | 14 + Modules/Backend/soil/__init__.py | 1 + Modules/Backend/soil/apps.py | 7 + Modules/Backend/soil/mock_data.py | 83 + Modules/Backend/soil/models.py | 1 + Modules/Backend/soil/serializers.py | 94 + Modules/Backend/soil/services.py | 84 + Modules/Backend/soil/tests.py | 367 + Modules/Backend/soil/urls.py | 15 + Modules/Backend/soil/views.py | 257 + Modules/Backend/water/__init__.py | 0 Modules/Backend/water/apps.py | 8 + Modules/Backend/water/defaults.py | 36 + .../Backend/water/migrations/0001_initial.py | 43 + Modules/Backend/water/migrations/__init__.py | 0 Modules/Backend/water/mock_data.py | 34 + Modules/Backend/water/models.py | 32 + Modules/Backend/water/serializers.py | 57 + Modules/Backend/water/services.py | 115 + Modules/Backend/water/tests.py | 200 + Modules/Backend/water/urls.py | 10 + Modules/Backend/water/views.py | 347 + Modules/Backend/water/weather_urls.py | 7 + Modules/Backend/yield_harvest/__init__.py | 0 Modules/Backend/yield_harvest/apps.py | 7 + .../yield_harvest/crop_simulation_urls.py | 19 + Modules/Backend/yield_harvest/defaults.py | 31 + .../yield_harvest/migrations/0001_initial.py | 43 + .../yield_harvest/migrations/__init__.py | 0 Modules/Backend/yield_harvest/mock_data.py | 209 + Modules/Backend/yield_harvest/models.py | 32 + Modules/Backend/yield_harvest/serializers.py | 192 + Modules/Backend/yield_harvest/services.py | 40 + Modules/Backend/yield_harvest/tests.py | 737 ++ Modules/Backend/yield_harvest/urls.py | 20 + Modules/Backend/yield_harvest/views.py | 543 ++ Modules/SensorHub/.cursor/postman.mdc | 74 + Modules/SensorHub/.cursor/project.mdc | 96 + Modules/SensorHub/.cursor/test-rule.mdc | 72 + Modules/SensorHub/.dockerignore | 16 + Modules/SensorHub/.env.example | 20 + .../SensorHub/.gitea/workflows/sensor-hub.yml | 120 + .../.github/workflows/sensor-hub.yml | 72 + Modules/SensorHub/.gitignore | 59 + Modules/SensorHub/.gitmodules | 4 + Modules/SensorHub/Dockerfile | 45 + Modules/SensorHub/Schemas/__init__.py | 57 + Modules/SensorHub/Schemas/common.py | 32 + .../crop_simulation_current_farm_chart.py | 42 + .../Schemas/crop_simulation_growth.py | 46 + .../Schemas/crop_simulation_growth_status.py | 59 + .../crop_simulation_harvest_prediction.py | 37 + .../crop_simulation_yield_prediction.py | 41 + Modules/SensorHub/Schemas/economy_overview.py | 47 + Modules/SensorHub/Schemas/farm_alerts.py | 90 + Modules/SensorHub/Schemas/farm_data_upsert.py | 53 + Modules/SensorHub/Schemas/farm_detail.py | 113 + Modules/SensorHub/Schemas/farm_parameter.py | 41 + .../Schemas/fertilization_recommend.py | 49 + Modules/SensorHub/Schemas/irrigation_list.py | 39 + .../SensorHub/Schemas/irrigation_methods.py | 91 + .../SensorHub/Schemas/irrigation_recommend.py | 49 + .../Schemas/irrigation_water_stress.py | 35 + Modules/SensorHub/Schemas/pest_disease.py | 118 + Modules/SensorHub/Schemas/plant.py | 107 + Modules/SensorHub/Schemas/rag_chat.py | 50 + Modules/SensorHub/Schemas/soil_data.py | 124 + .../Schemas/soile_anomaly_detection.py | 42 + .../SensorHub/Schemas/soile_health_summary.py | 37 + .../Schemas/soile_moisture_heatmap.py | 41 + .../SensorHub/Schemas/weather_farm_card.py | 41 + .../Schemas/weather_water_need_prediction.py | 46 + Modules/SensorHub/config/__init__.py | 0 Modules/SensorHub/config/asgi.py | 7 + Modules/SensorHub/config/settings.py | 105 + Modules/SensorHub/config/urls.py | 10 + Modules/SensorHub/config/wsgi.py | 7 + Modules/SensorHub/docker-compose-prod.yaml | 63 + Modules/SensorHub/docker-compose.yaml | 87 + Modules/SensorHub/ingest/__init__.py | 0 Modules/SensorHub/ingest/constants.py | 14 + .../SensorHub/ingest/management/__init__.py | 0 .../ingest/management/commands/__init__.py | 0 .../management/commands/send_sensor_data.py | 72 + .../ingest/templates/ingest/index.html | 166 + Modules/SensorHub/ingest/urls.py | 7 + Modules/SensorHub/ingest/views.py | 98 + Modules/SensorHub/manage.py | 22 + Modules/SensorHub/requirements.txt | 16 + PROJECT_WEAKNESSES_AUDIT_FA.md | 409 + SENSOR_ARCHITECTURE_RECOMMENDATION.md | 499 + SensorHub | 1 + Tests/config/apis.yaml | 26 +- Tests/logs/test.log | 34 +- Tests/tests/test_authentication.py | 25 +- Tests/utils/http_client.py | 32 +- Tests/utils/yaml_loader.py | 4 +- 854 files changed, 102985 insertions(+), 76 deletions(-) create mode 160000 Accsess create mode 160000 Ai create mode 100644 BACKEND_PLANTS_AI_FARMDATA_SYNC_PROPOSAL.md create mode 160000 Backend create mode 100644 Modules/Accsess/README.md create mode 100644 Modules/Accsess/config/opa-config.DEVELOP.yaml create mode 100644 Modules/Accsess/config/opa-config.default.yaml create mode 100644 Modules/Accsess/config/opa-config.yaml create mode 100644 Modules/Accsess/docker-compose.yaml create mode 100644 Modules/Accsess/logs/.gitkeep create mode 100644 Modules/Accsess/logs/opa.log create mode 100644 Modules/Accsess/policies/authz.rego create mode 100644 Modules/Accsess/policies/bootstrap-policies.json create mode 100644 Modules/Accsess/policies/sensor_7_in_1.rego create mode 100644 Modules/Accsess/scripts/__pycache__/opa_log_receiver.cpython-314.pyc create mode 100644 Modules/Accsess/scripts/opa_log_receiver.py create mode 100644 Modules/Ai/.cursor/postman.mdc create mode 100644 Modules/Ai/.cursor/project.mdc create mode 100644 Modules/Ai/.cursor/test-rule.mdc create mode 100644 Modules/Ai/.dockerignore create mode 100644 Modules/Ai/.env.example create mode 100644 Modules/Ai/.gitea/workflows/ai.yml create mode 100644 Modules/Ai/.github/workflows/ai.yml create mode 100644 Modules/Ai/.gitignore create mode 100644 Modules/Ai/.gitmodules create mode 100644 Modules/Ai/API_REFERENCE_FA.md create mode 100644 Modules/Ai/API_RELIABILITY_AUDIT_FA.md create mode 100644 Modules/Ai/APPS_URLS_AUDIT.md create mode 100644 Modules/Ai/Dockerfile create mode 100644 Modules/Ai/Dockerfile.Dev create mode 100644 Modules/Ai/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md create mode 100644 Modules/Ai/SENSOR_DASHBOARD_API_FA.md create mode 100644 Modules/Ai/Schemas/__init__.py create mode 100644 Modules/Ai/Schemas/common.py create mode 100644 Modules/Ai/Schemas/crop_simulation_current_farm_chart.py create mode 100644 Modules/Ai/Schemas/crop_simulation_growth.py create mode 100644 Modules/Ai/Schemas/crop_simulation_growth_status.py create mode 100644 Modules/Ai/Schemas/crop_simulation_harvest_prediction.py create mode 100644 Modules/Ai/Schemas/crop_simulation_yield_harvest_summary.py create mode 100644 Modules/Ai/Schemas/crop_simulation_yield_prediction.py create mode 100644 Modules/Ai/Schemas/economy_overview.py create mode 100644 Modules/Ai/Schemas/farm_data_upsert.py create mode 100644 Modules/Ai/Schemas/fertilization_recommend.py create mode 100644 Modules/Ai/Schemas/irrigation_list.py create mode 100644 Modules/Ai/Schemas/irrigation_recommend.py create mode 100644 Modules/Ai/Schemas/rag_chat.py create mode 100644 Modules/Ai/Schemas/soile_anomaly_detection.py create mode 100644 Modules/Ai/Schemas/soile_health_summary.py create mode 100644 Modules/Ai/Schemas/soile_moisture_heatmap.py create mode 100644 Modules/Ai/Schemas/weather_water_need_prediction.py create mode 100644 Modules/Ai/config/__init__.py create mode 100644 Modules/Ai/config/asgi.py create mode 100644 Modules/Ai/config/celery.py create mode 100644 Modules/Ai/config/integration_contract.py create mode 100644 Modules/Ai/config/knowledge_base/.gitkeep create mode 100644 Modules/Ai/config/knowledge_base/README.md create mode 100644 Modules/Ai/config/knowledge_base/chat/README.md create mode 100644 Modules/Ai/config/knowledge_base/chat/soil_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/fertilization/fertilization_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/irrigation/irrigation_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/pest_disease/pest_disease_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/soil_knowledge.txt create mode 100644 Modules/Ai/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt create mode 100644 Modules/Ai/config/openapi.py create mode 100644 Modules/Ai/config/rag_config.yaml create mode 100644 Modules/Ai/config/settings.py create mode 100644 Modules/Ai/config/settings_test.py create mode 100644 Modules/Ai/config/test_urls.py create mode 100644 Modules/Ai/config/tone.txt create mode 100644 Modules/Ai/config/tones/chat_tone.txt create mode 100644 Modules/Ai/config/tones/farm_alerts_tone.txt create mode 100644 Modules/Ai/config/tones/fertilization_plan_parser_tone.txt create mode 100644 Modules/Ai/config/tones/fertilization_tone.txt create mode 100644 Modules/Ai/config/tones/irrigation_plan_parser_tone.txt create mode 100644 Modules/Ai/config/tones/irrigation_tone.txt create mode 100644 Modules/Ai/config/tones/pest_disease_tone.txt create mode 100644 Modules/Ai/config/tones/soil_anomaly_tone.txt create mode 100644 Modules/Ai/config/tones/water_need_prediction_tone.txt create mode 100644 Modules/Ai/config/tones/yield_harvest_tone.txt create mode 100644 Modules/Ai/config/urls.py create mode 100644 Modules/Ai/config/user_info/.gitkeep create mode 100644 Modules/Ai/config/wsgi.py create mode 100644 Modules/Ai/constraints.txt create mode 100644 Modules/Ai/crop_simulation/SERVICES_INTEGRATION.md create mode 100644 Modules/Ai/crop_simulation/__init__.py create mode 100644 Modules/Ai/crop_simulation/apps.py create mode 100644 Modules/Ai/crop_simulation/growth_simulation.py create mode 100644 Modules/Ai/crop_simulation/harvest_prediction.py create mode 100644 Modules/Ai/crop_simulation/migrations/0001_initial.py create mode 100644 Modules/Ai/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py create mode 100644 Modules/Ai/crop_simulation/migrations/__init__.py create mode 100644 Modules/Ai/crop_simulation/models.py create mode 100644 Modules/Ai/crop_simulation/recommendation_optimizer.py create mode 100644 Modules/Ai/crop_simulation/serializers.py create mode 100644 Modules/Ai/crop_simulation/services.py create mode 100644 Modules/Ai/crop_simulation/tasks.py create mode 100644 Modules/Ai/crop_simulation/test_growth_simulation_api.py create mode 100644 Modules/Ai/crop_simulation/test_single_run.py create mode 100644 Modules/Ai/crop_simulation/test_single_run_with_recommendations.py create mode 100644 Modules/Ai/crop_simulation/tests.py create mode 100644 Modules/Ai/crop_simulation/urls.py create mode 100644 Modules/Ai/crop_simulation/views.py create mode 100644 Modules/Ai/crop_simulation/water_stress.py create mode 100644 Modules/Ai/crop_simulation/yield_harvest_summary.py create mode 100644 Modules/Ai/crop_simulation/yield_prediction.py create mode 100644 Modules/Ai/docker-compose-prod.yaml create mode 100644 Modules/Ai/docker-compose.yaml create mode 100644 Modules/Ai/docs/crop_simulation_api_reference.md create mode 100644 Modules/Ai/docs/farm_alerts_tracker_api.md create mode 100644 Modules/Ai/docs/farm_alerts_tracker_response_fields.md create mode 100644 Modules/Ai/docs/irrigation_fertilization_plan_parser_apis.md create mode 100644 Modules/Ai/docs/location_and_farm_data_apps_explained.md create mode 100644 Modules/Ai/docs/location_data_current_structure.md create mode 100644 Modules/Ai/docs/location_data_current_workflow.md create mode 100644 Modules/Ai/docs/location_data_full_architecture.md create mode 100644 Modules/Ai/docs/pcse_api_list.md create mode 100644 Modules/Ai/docs/updated_pcse_apis_reference.md create mode 100644 Modules/Ai/docs/yield_harvest_pcse_rag_api_plan.md create mode 100644 Modules/Ai/economy/__init__.py create mode 100644 Modules/Ai/economy/apps.py create mode 100644 Modules/Ai/economy/serializers.py create mode 100644 Modules/Ai/economy/services.py create mode 100644 Modules/Ai/economy/test_economic_overview_api.py create mode 100644 Modules/Ai/economy/urls.py create mode 100644 Modules/Ai/economy/views.py create mode 100644 Modules/Ai/entrypoint.sh create mode 100644 Modules/Ai/farm_alerts/__init__.py create mode 100644 Modules/Ai/farm_alerts/alerts_tracker.py create mode 100644 Modules/Ai/farm_alerts/apps.py create mode 100644 Modules/Ai/farm_alerts/migrations/0001_initial.py create mode 100644 Modules/Ai/farm_alerts/migrations/__init__.py create mode 100644 Modules/Ai/farm_alerts/models.py create mode 100644 Modules/Ai/farm_alerts/serializers.py create mode 100644 Modules/Ai/farm_alerts/services.py create mode 100644 Modules/Ai/farm_alerts/urls.py create mode 100644 Modules/Ai/farm_alerts/views.py create mode 100644 Modules/Ai/farm_data/__init__.py create mode 100644 Modules/Ai/farm_data/admin.py create mode 100644 Modules/Ai/farm_data/apps.py create mode 100644 Modules/Ai/farm_data/context.py create mode 100644 Modules/Ai/farm_data/management/__init__.py create mode 100644 Modules/Ai/farm_data/management/commands/__init__.py create mode 100644 Modules/Ai/farm_data/management/commands/seed_farm_data.py create mode 100644 Modules/Ai/farm_data/management/commands/seed_sensor_parameters.py create mode 100644 Modules/Ai/farm_data/migrations/0001_initial.py create mode 100644 Modules/Ai/farm_data/migrations/0002_seed_initial_parameters.py create mode 100644 Modules/Ai/farm_data/migrations/0003_sensordata_plants.py create mode 100644 Modules/Ai/farm_data/migrations/0004_alter_sensordata_location.py create mode 100644 Modules/Ai/farm_data/migrations/0005_delete_sensordatahistory.py create mode 100644 Modules/Ai/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py create mode 100644 Modules/Ai/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py create mode 100644 Modules/Ai/farm_data/migrations/0008_rename_location_to_center_location.py create mode 100644 Modules/Ai/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py create mode 100644 Modules/Ai/farm_data/migrations/0010_rename_tables_to_farm_data.py create mode 100644 Modules/Ai/farm_data/migrations/0011_sensordata_irrigation_method.py create mode 100644 Modules/Ai/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py create mode 100644 Modules/Ai/farm_data/migrations/__init__.py create mode 100644 Modules/Ai/farm_data/models.py create mode 100644 Modules/Ai/farm_data/postman/farm_data.json create mode 100644 Modules/Ai/farm_data/serializers.py create mode 100644 Modules/Ai/farm_data/services.py create mode 100644 Modules/Ai/farm_data/tests/__init__.py create mode 100644 Modules/Ai/farm_data/tests/test_farm_detail_api.py create mode 100644 Modules/Ai/farm_data/urls.py create mode 100644 Modules/Ai/farm_data/views.py create mode 100644 Modules/Ai/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md create mode 100644 Modules/Ai/fertilization/__init__.py create mode 100644 Modules/Ai/fertilization/apps.py create mode 100644 Modules/Ai/fertilization/serializers.py create mode 100644 Modules/Ai/fertilization/urls.py create mode 100644 Modules/Ai/fertilization/views.py create mode 100644 Modules/Ai/integration_tests/README.md create mode 100644 Modules/Ai/integration_tests/__init__.py create mode 100644 Modules/Ai/integration_tests/base.py create mode 100644 Modules/Ai/integration_tests/test_management_api_flow.py create mode 100644 Modules/Ai/integration_tests/test_reporting_and_ai_api_flow.py create mode 100644 Modules/Ai/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md create mode 100644 Modules/Ai/irrigation/__init__.py create mode 100644 Modules/Ai/irrigation/admin.py create mode 100644 Modules/Ai/irrigation/apps.py create mode 100644 Modules/Ai/irrigation/evapotranspiration.py create mode 100644 Modules/Ai/irrigation/indicators.py create mode 100644 Modules/Ai/irrigation/management/__init__.py create mode 100644 Modules/Ai/irrigation/management/commands/__init__.py create mode 100644 Modules/Ai/irrigation/management/commands/seed_irrigation_methods.py create mode 100644 Modules/Ai/irrigation/migrations/0001_initial.py create mode 100644 Modules/Ai/irrigation/migrations/__init__.py create mode 100644 Modules/Ai/irrigation/models.py create mode 100644 Modules/Ai/irrigation/serializers.py create mode 100644 Modules/Ai/irrigation/test_irrigation_recommend_api.py create mode 100644 Modules/Ai/irrigation/test_water_stress_api.py create mode 100644 Modules/Ai/irrigation/urls.py create mode 100644 Modules/Ai/irrigation/views.py create mode 100644 Modules/Ai/location_data/LOCATION_DATA_FLOW.md create mode 100644 Modules/Ai/location_data/__init__.py create mode 100644 Modules/Ai/location_data/admin.py create mode 100644 Modules/Ai/location_data/apps.py create mode 100644 Modules/Ai/location_data/block_subdivision.py create mode 100644 Modules/Ai/location_data/data_driven_subdivision.py create mode 100644 Modules/Ai/location_data/grid_analysis.py create mode 100644 Modules/Ai/location_data/management/__init__.py create mode 100644 Modules/Ai/location_data/management/commands/__init__.py create mode 100644 Modules/Ai/location_data/management/commands/rename_soil_data_label.py create mode 100644 Modules/Ai/location_data/management/commands/repair_location_tables.py create mode 100644 Modules/Ai/location_data/migrations/0001_initial.py create mode 100644 Modules/Ai/location_data/migrations/0002_soildepthdata_refactor.py create mode 100644 Modules/Ai/location_data/migrations/0002_soillocation_ideal_sensor_profile.py create mode 100644 Modules/Ai/location_data/migrations/0003_rename_app_label.py create mode 100644 Modules/Ai/location_data/migrations/0004_soillocation_farm_boundary.py create mode 100644 Modules/Ai/location_data/migrations/0005_merge_20260327_0840.py create mode 100644 Modules/Ai/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py create mode 100644 Modules/Ai/location_data/migrations/0007_ndviobservation.py create mode 100644 Modules/Ai/location_data/migrations/0008_soillocation_block_layout.py create mode 100644 Modules/Ai/location_data/migrations/0009_blocksubdivision.py create mode 100644 Modules/Ai/location_data/migrations/0010_blocksubdivision_elbow_plot.py create mode 100644 Modules/Ai/location_data/migrations/0011_remote_sensing_models.py create mode 100644 Modules/Ai/location_data/migrations/0012_remote_sensing_subdivision_models.py create mode 100644 Modules/Ai/location_data/migrations/0013_remove_soildepthdata.py create mode 100644 Modules/Ai/location_data/migrations/__init__.py create mode 100644 Modules/Ai/location_data/models.py create mode 100644 Modules/Ai/location_data/ndvi.py create mode 100644 Modules/Ai/location_data/openeo_service.py create mode 100644 Modules/Ai/location_data/postman/soil_data.json create mode 100644 Modules/Ai/location_data/remote_sensing.py create mode 100644 Modules/Ai/location_data/satellite_snapshot.py create mode 100644 Modules/Ai/location_data/serializers.py create mode 100644 Modules/Ai/location_data/tasks.py create mode 100644 Modules/Ai/location_data/test_block_subdivision.py create mode 100644 Modules/Ai/location_data/test_grid_analysis.py create mode 100644 Modules/Ai/location_data/test_ndvi_health_api.py create mode 100644 Modules/Ai/location_data/test_openeo_service.py create mode 100644 Modules/Ai/location_data/test_remote_sensing_api.py create mode 100644 Modules/Ai/location_data/test_soil_api.py create mode 100644 Modules/Ai/location_data/urls.py create mode 100644 Modules/Ai/location_data/views.py create mode 100644 Modules/Ai/manage.py create mode 100644 Modules/Ai/pest_disease/__init__.py create mode 100644 Modules/Ai/pest_disease/apps.py create mode 100644 Modules/Ai/pest_disease/serializers.py create mode 100644 Modules/Ai/pest_disease/urls.py create mode 100644 Modules/Ai/pest_disease/views.py create mode 100644 Modules/Ai/plant/PLANT_NAMES_API.md create mode 100644 Modules/Ai/plant/__init__.py create mode 100644 Modules/Ai/plant/admin.py create mode 100644 Modules/Ai/plant/apps.py create mode 100644 Modules/Ai/plant/gdd.py create mode 100644 Modules/Ai/plant/management/__init__.py create mode 100644 Modules/Ai/plant/management/commands/__init__.py create mode 100644 Modules/Ai/plant/management/commands/seed_plants.py create mode 100644 Modules/Ai/plant/migrations/0001_initial.py create mode 100644 Modules/Ai/plant/migrations/0002_plant_health_profile.py create mode 100644 Modules/Ai/plant/migrations/0003_plant_irrigation_profile.py create mode 100644 Modules/Ai/plant/migrations/0004_plant_growth_profile.py create mode 100644 Modules/Ai/plant/migrations/0005_plant_growth_stage.py create mode 100644 Modules/Ai/plant/migrations/0006_plant_icon.py create mode 100644 Modules/Ai/plant/migrations/__init__.py create mode 100644 Modules/Ai/plant/models.py create mode 100644 Modules/Ai/plant/serializers.py create mode 100644 Modules/Ai/plant/services.py create mode 100644 Modules/Ai/plant/urls.py create mode 100644 Modules/Ai/plant/views.py create mode 100644 Modules/Ai/psce_doc.txt create mode 100644 Modules/Ai/rag/RAG_LOGIC_SIMPLE.md create mode 100644 Modules/Ai/rag/README.md create mode 100644 Modules/Ai/rag/__init__.py create mode 100644 Modules/Ai/rag/api_provider.py create mode 100644 Modules/Ai/rag/apps.py create mode 100644 Modules/Ai/rag/chat.py create mode 100644 Modules/Ai/rag/chunker.py create mode 100644 Modules/Ai/rag/client.py create mode 100644 Modules/Ai/rag/config.py create mode 100644 Modules/Ai/rag/embedding.py create mode 100644 Modules/Ai/rag/failure_contract.py create mode 100644 Modules/Ai/rag/ingest.py create mode 100644 Modules/Ai/rag/management/__init__.py create mode 100644 Modules/Ai/rag/management/commands/__init__.py create mode 100644 Modules/Ai/rag/management/commands/rag_ingest.py create mode 100644 Modules/Ai/rag/migrations/0001_initial.py create mode 100644 Modules/Ai/rag/migrations/__init__.py create mode 100644 Modules/Ai/rag/models.py create mode 100644 Modules/Ai/rag/observability.py create mode 100644 Modules/Ai/rag/retrieve.py create mode 100644 Modules/Ai/rag/services/__init__.py create mode 100644 Modules/Ai/rag/services/fertilization.py create mode 100644 Modules/Ai/rag/services/fertilization_plan_parser.py create mode 100644 Modules/Ai/rag/services/irrigation.py create mode 100644 Modules/Ai/rag/services/irrigation_plan_parser.py create mode 100644 Modules/Ai/rag/services/pest_disease.py create mode 100644 Modules/Ai/rag/services/soil_anomaly.py create mode 100644 Modules/Ai/rag/services/water_need_prediction.py create mode 100644 Modules/Ai/rag/services/yield_harvest.py create mode 100644 Modules/Ai/rag/tasks.py create mode 100644 Modules/Ai/rag/tests/test_chat_context.py create mode 100644 Modules/Ai/rag/tests/test_failure_contracts.py create mode 100644 Modules/Ai/rag/tests/test_observability.py create mode 100644 Modules/Ai/rag/tests/test_recommendation_services.py create mode 100644 Modules/Ai/rag/urls.py create mode 100644 Modules/Ai/rag/user_data.py create mode 100644 Modules/Ai/rag/vector_store.py create mode 100644 Modules/Ai/rag/views.py create mode 100644 Modules/Ai/requirements.txt create mode 100644 Modules/Ai/scripts/fix_farm_data_tables.sh create mode 100644 Modules/Ai/scripts/generate_mock_data.py create mode 100644 Modules/Ai/soile/__init__.py create mode 100644 Modules/Ai/soile/anomaly_detection.py create mode 100644 Modules/Ai/soile/apps.py create mode 100644 Modules/Ai/soile/health_summary.py create mode 100644 Modules/Ai/soile/serializers.py create mode 100644 Modules/Ai/soile/services.py create mode 100644 Modules/Ai/soile/test_soil_moisture_heatmap_api.py create mode 100644 Modules/Ai/soile/urls.py create mode 100644 Modules/Ai/soile/views.py create mode 100644 Modules/Ai/weather/__init__.py create mode 100644 Modules/Ai/weather/adapters.py create mode 100644 Modules/Ai/weather/admin.py create mode 100644 Modules/Ai/weather/apps.py create mode 100644 Modules/Ai/weather/farm_weather.py create mode 100644 Modules/Ai/weather/management/__init__.py create mode 100644 Modules/Ai/weather/management/commands/__init__.py create mode 100644 Modules/Ai/weather/management/commands/seed_weather_data.py create mode 100644 Modules/Ai/weather/management/commands/seed_weather_parameters.py create mode 100644 Modules/Ai/weather/migrations/0001_initial.py create mode 100644 Modules/Ai/weather/migrations/0002_seed_weather_parameters.py create mode 100644 Modules/Ai/weather/migrations/0003_seed_weather_forecasts.py create mode 100644 Modules/Ai/weather/migrations/__init__.py create mode 100644 Modules/Ai/weather/models.py create mode 100644 Modules/Ai/weather/serializers.py create mode 100644 Modules/Ai/weather/services.py create mode 100644 Modules/Ai/weather/tasks.py create mode 100644 Modules/Ai/weather/test_adapters.py create mode 100644 Modules/Ai/weather/test_farm_weather_api.py create mode 100644 Modules/Ai/weather/urls.py create mode 100644 Modules/Ai/weather/views.py create mode 100644 Modules/Ai/weather/water_need_prediction.py create mode 100644 Modules/Backend/.cursor/postman.mdc create mode 100644 Modules/Backend/.cursor/project.mdc create mode 100644 Modules/Backend/.cursor/test-rule.mdc create mode 100644 Modules/Backend/.dockerignore create mode 100644 Modules/Backend/.env.example create mode 100644 Modules/Backend/.gitea/workflows/backend.yml create mode 100644 Modules/Backend/.github/workflows/backend.yml create mode 100644 Modules/Backend/.gitignore create mode 100644 Modules/Backend/.gitmodules create mode 100644 Modules/Backend/AGENTS.md create mode 100644 Modules/Backend/AI_INTEGRATION_FLOW_CONTRACT.md create mode 100644 Modules/Backend/AI_ROUTE_CONNECTION_AUDIT.md create mode 100644 Modules/Backend/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md create mode 100644 Modules/Backend/Dockerfile create mode 100644 Modules/Backend/Dockerfile.Dev create mode 100644 Modules/Backend/access_control/__init__.py create mode 100644 Modules/Backend/access_control/apps.py create mode 100644 Modules/Backend/access_control/catalog.py create mode 100644 Modules/Backend/access_control/middleware.py create mode 100644 Modules/Backend/access_control/migrations/0001_initial.py create mode 100644 Modules/Backend/access_control/migrations/0002_link_subscription_plan_to_farm.py create mode 100644 Modules/Backend/access_control/migrations/0003_seed_default_access_rules.py create mode 100644 Modules/Backend/access_control/migrations/0004_enable_default_feature_access.py create mode 100644 Modules/Backend/access_control/migrations/0005_backfill_farm_subscription_plans.py create mode 100644 Modules/Backend/access_control/migrations/__init__.py create mode 100644 Modules/Backend/access_control/models.py create mode 100644 Modules/Backend/access_control/permissions.py create mode 100644 Modules/Backend/access_control/serializers.py create mode 100644 Modules/Backend/access_control/services.py create mode 100644 Modules/Backend/access_control/tests.py create mode 100644 Modules/Backend/access_control/urls.py create mode 100644 Modules/Backend/access_control/views.py create mode 100644 Modules/Backend/account/__init__.py create mode 100644 Modules/Backend/account/apps.py create mode 100644 Modules/Backend/account/backends.py create mode 100644 Modules/Backend/account/management/__init__.py create mode 100644 Modules/Backend/account/management/commands/__init__.py create mode 100644 Modules/Backend/account/management/commands/seed_admin_user.py create mode 100644 Modules/Backend/account/migrations/0001_initial.py create mode 100644 Modules/Backend/account/migrations/0002_alter_user_managers_alter_user_email.py create mode 100644 Modules/Backend/account/migrations/__init__.py create mode 100644 Modules/Backend/account/models.py create mode 100644 Modules/Backend/account/postman/account.json create mode 100644 Modules/Backend/account/seeds.py create mode 100644 Modules/Backend/account/serializers.py create mode 100644 Modules/Backend/account/urls.py create mode 100644 Modules/Backend/account/views.py create mode 100644 Modules/Backend/api_changes_last_6_commits_combined.md create mode 100644 Modules/Backend/auth/__init__.py create mode 100644 Modules/Backend/auth/apps.py create mode 100644 Modules/Backend/auth/postman/postman.json create mode 100644 Modules/Backend/auth/serializers.py create mode 100644 Modules/Backend/auth/sms_service.py create mode 100644 Modules/Backend/auth/urls.py create mode 100644 Modules/Backend/auth/views.py create mode 100644 Modules/Backend/celerybeat-schedule create mode 100644 Modules/Backend/config/__init__.py create mode 100644 Modules/Backend/config/asgi.py create mode 100644 Modules/Backend/config/celery.py create mode 100644 Modules/Backend/config/failure_contract.py create mode 100644 Modules/Backend/config/feature.json create mode 100644 Modules/Backend/config/integration_contract.py create mode 100644 Modules/Backend/config/observability.py create mode 100644 Modules/Backend/config/settings.py create mode 100644 Modules/Backend/config/swagger.py create mode 100644 Modules/Backend/config/urls.py create mode 100644 Modules/Backend/config/wsgi.py create mode 100644 Modules/Backend/crop_health/__init__.py create mode 100644 Modules/Backend/crop_health/apps.py create mode 100644 Modules/Backend/crop_health/mock_data.py create mode 100644 Modules/Backend/crop_health/models.py create mode 100644 Modules/Backend/crop_health/serializers.py create mode 100644 Modules/Backend/crop_health/services.py create mode 100644 Modules/Backend/crop_health/tests.py create mode 100644 Modules/Backend/crop_health/urls.py create mode 100644 Modules/Backend/crop_health/views.py create mode 100644 Modules/Backend/crop_zoning/CROP_ZONING_CODE_LOGIC.md create mode 100644 Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_API.md create mode 100644 Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md create mode 100644 Modules/Backend/crop_zoning/__init__.py create mode 100644 Modules/Backend/crop_zoning/apps.py create mode 100644 Modules/Backend/crop_zoning/defaults.py create mode 100644 Modules/Backend/crop_zoning/migrations/0001_initial.py create mode 100644 Modules/Backend/crop_zoning/migrations/0002_crop_zoning_mock_schema.py create mode 100644 Modules/Backend/crop_zoning/migrations/0003_zone_processing_and_analysis.py create mode 100644 Modules/Backend/crop_zoning/migrations/0004_croparea_farm.py create mode 100644 Modules/Backend/crop_zoning/migrations/__init__.py create mode 100644 Modules/Backend/crop_zoning/mock_data.py create mode 100644 Modules/Backend/crop_zoning/models.py create mode 100644 Modules/Backend/crop_zoning/postman/crop_zoning.json create mode 100644 Modules/Backend/crop_zoning/services.py create mode 100644 Modules/Backend/crop_zoning/tasks.py create mode 100644 Modules/Backend/crop_zoning/tests.py create mode 100644 Modules/Backend/crop_zoning/urls.py create mode 100644 Modules/Backend/crop_zoning/views.py create mode 100644 Modules/Backend/dashboard/__init__.py create mode 100644 Modules/Backend/dashboard/apps.py create mode 100644 Modules/Backend/dashboard/defaults.py create mode 100644 Modules/Backend/dashboard/migrations/0001_initial.py create mode 100644 Modules/Backend/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py create mode 100644 Modules/Backend/dashboard/migrations/__init__.py create mode 100644 Modules/Backend/dashboard/mock_data.py create mode 100644 Modules/Backend/dashboard/models.py create mode 100644 Modules/Backend/dashboard/postman/farm_dashboard.json create mode 100644 Modules/Backend/dashboard/serializers.py create mode 100644 Modules/Backend/dashboard/services.py create mode 100644 Modules/Backend/dashboard/templates.py create mode 100644 Modules/Backend/dashboard/tests.py create mode 100644 Modules/Backend/dashboard/urls.py create mode 100644 Modules/Backend/dashboard/urls_config.py create mode 100644 Modules/Backend/dashboard/views.py create mode 100644 Modules/Backend/device_hub/API_GUIDE.md create mode 100644 Modules/Backend/device_hub/__init__.py create mode 100644 Modules/Backend/device_hub/apps.py create mode 100644 Modules/Backend/device_hub/authentication.py create mode 100644 Modules/Backend/device_hub/catalog_seed.py create mode 100644 Modules/Backend/device_hub/comparison_urls.py create mode 100644 Modules/Backend/device_hub/management/__init__.py create mode 100644 Modules/Backend/device_hub/management/commands/__init__.py create mode 100644 Modules/Backend/device_hub/management/commands/seed_sensor_7_in_1.py create mode 100644 Modules/Backend/device_hub/management/commands/seed_sensor_catalog.py create mode 100644 Modules/Backend/device_hub/migrations/0001_initial.py create mode 100644 Modules/Backend/device_hub/migrations/0002_absorb_sensor_7_in_1.py create mode 100644 Modules/Backend/device_hub/migrations/0003_absorb_sensor_external_api.py create mode 100644 Modules/Backend/device_hub/migrations/0004_absorb_sensor_catalog.py create mode 100644 Modules/Backend/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py create mode 100644 Modules/Backend/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py create mode 100644 Modules/Backend/device_hub/migrations/0007_devicecatalog_dynamic_fields.py create mode 100644 Modules/Backend/device_hub/migrations/0008_farmdevice_device_catalogs.py create mode 100644 Modules/Backend/device_hub/migrations/0009_sync_devicecatalog_schema.py create mode 100644 Modules/Backend/device_hub/migrations/__init__.py create mode 100644 Modules/Backend/device_hub/mock_data.py create mode 100644 Modules/Backend/device_hub/models.py create mode 100644 Modules/Backend/device_hub/seeds.py create mode 100644 Modules/Backend/device_hub/sensor_7_in_1_urls.py create mode 100644 Modules/Backend/device_hub/sensor_catalog_urls.py create mode 100644 Modules/Backend/device_hub/sensor_external_api_urls.py create mode 100644 Modules/Backend/device_hub/sensor_serializers.py create mode 100644 Modules/Backend/device_hub/serializers.py create mode 100644 Modules/Backend/device_hub/services.py create mode 100644 Modules/Backend/device_hub/templates.py create mode 100644 Modules/Backend/device_hub/tests.py create mode 100644 Modules/Backend/device_hub/urls.py create mode 100644 Modules/Backend/device_hub/views.py create mode 100644 Modules/Backend/docker-compose-prod.yaml create mode 100644 Modules/Backend/docker-compose.yaml create mode 100644 Modules/Backend/docs/dashboard_api_reference.md create mode 100644 Modules/Backend/docs/dashboard_card_service_map.md create mode 100644 Modules/Backend/docs/device_catalog_dynamic_architecture.md create mode 100644 Modules/Backend/docs/fertilization_recommendation_frontend.md create mode 100644 Modules/Backend/docs/irrigation_fertilization_plan_parser_apis.md create mode 100644 Modules/Backend/docs/pest_disease_risk_summary_frontend_api_reference.md create mode 100644 Modules/Backend/docs/recommend_task_status_frontend_backend.md create mode 100644 Modules/Backend/docs/sensor_frontend_api_reference.md create mode 100644 Modules/Backend/docs/soil_frontend_api_reference.md create mode 100644 Modules/Backend/docs/water_weather_frontend_api_reference.md create mode 100644 Modules/Backend/docs/yield_harvest_ai_integration.md create mode 100644 Modules/Backend/docs/yield_harvest_prediction_api_changes.md create mode 100644 Modules/Backend/economic_overview/__init__.py create mode 100644 Modules/Backend/economic_overview/apps.py create mode 100644 Modules/Backend/economic_overview/defaults.py create mode 100644 Modules/Backend/economic_overview/migrations/0001_initial.py create mode 100644 Modules/Backend/economic_overview/migrations/__init__.py create mode 100644 Modules/Backend/economic_overview/mock_data.py create mode 100644 Modules/Backend/economic_overview/models.py create mode 100644 Modules/Backend/economic_overview/serializers.py create mode 100644 Modules/Backend/economic_overview/services.py create mode 100644 Modules/Backend/economic_overview/tests.py create mode 100644 Modules/Backend/economic_overview/urls.py create mode 100644 Modules/Backend/economic_overview/views.py create mode 100644 Modules/Backend/entrypoint.sh create mode 100644 Modules/Backend/external_api_adapter/README.md create mode 100644 Modules/Backend/external_api_adapter/__init__.py create mode 100644 Modules/Backend/external_api_adapter/adapter.py create mode 100644 Modules/Backend/external_api_adapter/apps.py create mode 100644 Modules/Backend/external_api_adapter/exceptions.py create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/index.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/methods/get_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_201.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/pest-detection/risk-summary/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/create-post_201.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/create-post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-get_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-get_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-put_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-put_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/detail-put_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_503.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/plant/list-get_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/predict/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/chat-post_200_stream.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_invalid_service.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_query.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_user.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/chat/generate/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_202.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_201.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_404.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/get_200_database.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/get_202_queued.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/get_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/post_200_database.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/post_202_queued.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/post_400.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/tasks/post_200.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_failure.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_pending.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_progress.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/weather-forecast/card/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/ai/yield-harvest/summary/get_200_success.json create mode 100644 Modules/Backend/external_api_adapter/json/sensor_hub/.gitkeep create mode 100644 Modules/Backend/external_api_adapter/mock_loader.py create mode 100644 Modules/Backend/external_api_adapter/services.py create mode 100644 Modules/Backend/external_api_adapter/tests.py create mode 100644 Modules/Backend/farm_ai_assistant/__init__.py create mode 100644 Modules/Backend/farm_ai_assistant/chat_api_changes_last_6_commits.md create mode 100644 Modules/Backend/farm_ai_assistant/defaults.py create mode 100644 Modules/Backend/farm_ai_assistant/migrations/0001_initial.py create mode 100644 Modules/Backend/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py create mode 100644 Modules/Backend/farm_ai_assistant/migrations/__init__.py create mode 100644 Modules/Backend/farm_ai_assistant/mock_data.py create mode 100644 Modules/Backend/farm_ai_assistant/models.py create mode 100644 Modules/Backend/farm_ai_assistant/postman/farm_ai_assistant.json create mode 100644 Modules/Backend/farm_ai_assistant/serializers.py create mode 100644 Modules/Backend/farm_ai_assistant/tests.py create mode 100644 Modules/Backend/farm_ai_assistant/urls.py create mode 100644 Modules/Backend/farm_ai_assistant/views.py create mode 100644 Modules/Backend/farm_alerts/TRACKER_API_FRONTEND.md create mode 100644 Modules/Backend/farm_alerts/__init__.py create mode 100644 Modules/Backend/farm_alerts/apps.py create mode 100644 Modules/Backend/farm_alerts/defaults.py create mode 100644 Modules/Backend/farm_alerts/migrations/0001_initial.py create mode 100644 Modules/Backend/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py create mode 100644 Modules/Backend/farm_alerts/migrations/0003_farmalert_tracker_fields.py create mode 100644 Modules/Backend/farm_alerts/migrations/0004_farmalerttrackersnapshot.py create mode 100644 Modules/Backend/farm_alerts/migrations/__init__.py create mode 100644 Modules/Backend/farm_alerts/mock_data.py create mode 100644 Modules/Backend/farm_alerts/models.py create mode 100644 Modules/Backend/farm_alerts/serializers.py create mode 100644 Modules/Backend/farm_alerts/services.py create mode 100644 Modules/Backend/farm_alerts/tasks.py create mode 100644 Modules/Backend/farm_alerts/tests.py create mode 100644 Modules/Backend/farm_alerts/urls.py create mode 100644 Modules/Backend/farm_alerts/views.py create mode 100644 Modules/Backend/farm_hub/API_REFERENCE_FA.md create mode 100644 Modules/Backend/farm_hub/__init__.py create mode 100644 Modules/Backend/farm_hub/apps.py create mode 100644 Modules/Backend/farm_hub/catalog.py create mode 100644 Modules/Backend/farm_hub/management/__init__.py create mode 100644 Modules/Backend/farm_hub/management/commands/__init__.py create mode 100644 Modules/Backend/farm_hub/management/commands/seed_admin_farm.py create mode 100644 Modules/Backend/farm_hub/management/commands/seed_farm_catalog.py create mode 100644 Modules/Backend/farm_hub/migrations/0001_initial.py create mode 100644 Modules/Backend/farm_hub/migrations/0002_seed_default_catalog.py create mode 100644 Modules/Backend/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py create mode 100644 Modules/Backend/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py create mode 100644 Modules/Backend/farm_hub/migrations/0005_product_profiles_and_plant_migration.py create mode 100644 Modules/Backend/farm_hub/migrations/0006_seed_expanded_product_catalog.py create mode 100644 Modules/Backend/farm_hub/migrations/0007_farmhub_subscription_plan.py create mode 100644 Modules/Backend/farm_hub/migrations/0008_product_plant_selector_fields.py create mode 100644 Modules/Backend/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py create mode 100644 Modules/Backend/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py create mode 100644 Modules/Backend/farm_hub/migrations/__init__.py create mode 100644 Modules/Backend/farm_hub/models.py create mode 100644 Modules/Backend/farm_hub/postman/farm_hub.json create mode 100644 Modules/Backend/farm_hub/seeds.py create mode 100644 Modules/Backend/farm_hub/serializers.py create mode 100644 Modules/Backend/farm_hub/services.py create mode 100644 Modules/Backend/farm_hub/tests.py create mode 100644 Modules/Backend/farm_hub/urls.py create mode 100644 Modules/Backend/farm_hub/views.py create mode 100644 Modules/Backend/farmer_calendar/FARMER_CALENDAR_API.md create mode 100644 Modules/Backend/farmer_calendar/__init__.py create mode 100644 Modules/Backend/farmer_calendar/apps.py create mode 100644 Modules/Backend/farmer_calendar/enums.py create mode 100644 Modules/Backend/farmer_calendar/migrations/0001_initial.py create mode 100644 Modules/Backend/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py create mode 100644 Modules/Backend/farmer_calendar/migrations/__init__.py create mode 100644 Modules/Backend/farmer_calendar/models.py create mode 100644 Modules/Backend/farmer_calendar/serializers.py create mode 100644 Modules/Backend/farmer_calendar/tests.py create mode 100644 Modules/Backend/farmer_calendar/urls.py create mode 100644 Modules/Backend/farmer_calendar/views.py create mode 100644 Modules/Backend/farmer_todos/FARMER_TODOS_API.md create mode 100644 Modules/Backend/farmer_todos/__init__.py create mode 100644 Modules/Backend/farmer_todos/apps.py create mode 100644 Modules/Backend/farmer_todos/migrations/0001_initial.py create mode 100644 Modules/Backend/farmer_todos/migrations/0002_merge_todos_into_calendar.py create mode 100644 Modules/Backend/farmer_todos/migrations/__init__.py create mode 100644 Modules/Backend/farmer_todos/models.py create mode 100644 Modules/Backend/farmer_todos/serializers.py create mode 100644 Modules/Backend/farmer_todos/tests.py create mode 100644 Modules/Backend/farmer_todos/urls.py create mode 100644 Modules/Backend/farmer_todos/views.py create mode 100644 Modules/Backend/fertilization/FERTILIZATION_PLAN_APIS.md create mode 100644 Modules/Backend/fertilization/__init__.py create mode 100644 Modules/Backend/fertilization/apps.py create mode 100644 Modules/Backend/fertilization/defaults.py create mode 100644 Modules/Backend/fertilization/migrations/0001_initial.py create mode 100644 Modules/Backend/fertilization/migrations/0002_recommendation_status_lifecycle.py create mode 100644 Modules/Backend/fertilization/migrations/0003_fertilizationplan.py create mode 100644 Modules/Backend/fertilization/migrations/0004_fertilizationplan_default_inactive.py create mode 100644 Modules/Backend/fertilization/migrations/__init__.py create mode 100644 Modules/Backend/fertilization/mock_data.py create mode 100644 Modules/Backend/fertilization/models.py create mode 100644 Modules/Backend/fertilization/postman/fertilization_recommendation.json create mode 100644 Modules/Backend/fertilization/serializers.py create mode 100644 Modules/Backend/fertilization/services.py create mode 100644 Modules/Backend/fertilization/tests.py create mode 100644 Modules/Backend/fertilization/urls.py create mode 100644 Modules/Backend/fertilization/views.py create mode 100644 Modules/Backend/irrigation/API_REFERENCE_FA.md create mode 100644 Modules/Backend/irrigation/IRRIGATION_PLAN_APIS.md create mode 100644 Modules/Backend/irrigation/__init__.py create mode 100644 Modules/Backend/irrigation/apps.py create mode 100644 Modules/Backend/irrigation/defaults.py create mode 100644 Modules/Backend/irrigation/migrations/0001_initial.py create mode 100644 Modules/Backend/irrigation/migrations/0002_recommendation_status_and_growth_stage.py create mode 100644 Modules/Backend/irrigation/migrations/0003_irrigationplan.py create mode 100644 Modules/Backend/irrigation/migrations/0004_irrigationplan_default_inactive.py create mode 100644 Modules/Backend/irrigation/migrations/__init__.py create mode 100644 Modules/Backend/irrigation/mock_data.py create mode 100644 Modules/Backend/irrigation/models.py create mode 100644 Modules/Backend/irrigation/postman/irrigation_recommendation.json create mode 100644 Modules/Backend/irrigation/serializers.py create mode 100644 Modules/Backend/irrigation/services.py create mode 100644 Modules/Backend/irrigation/tests.py create mode 100644 Modules/Backend/irrigation/urls.py create mode 100644 Modules/Backend/irrigation/views.py create mode 100644 Modules/Backend/manage.py create mode 100644 Modules/Backend/notifications/NOTIFICATION_API_CHANGES.md create mode 100644 Modules/Backend/notifications/__init__.py create mode 100644 Modules/Backend/notifications/apps.py create mode 100644 Modules/Backend/notifications/migrations/0001_initial.py create mode 100644 Modules/Backend/notifications/migrations/0002_farmnotification_tracker_fields.py create mode 100644 Modules/Backend/notifications/migrations/__init__.py create mode 100644 Modules/Backend/notifications/models.py create mode 100644 Modules/Backend/notifications/serializers.py create mode 100644 Modules/Backend/notifications/services.py create mode 100644 Modules/Backend/notifications/tests.py create mode 100644 Modules/Backend/notifications/urls.py create mode 100644 Modules/Backend/notifications/views.py create mode 100644 Modules/Backend/pest_detection/__init__.py create mode 100644 Modules/Backend/pest_detection/apps.py create mode 100644 Modules/Backend/pest_detection/mock_data.py create mode 100644 Modules/Backend/pest_detection/pest_disease_urls.py create mode 100644 Modules/Backend/pest_detection/postman/pest_detection.json create mode 100644 Modules/Backend/pest_detection/serializers.py create mode 100644 Modules/Backend/pest_detection/services.py create mode 100644 Modules/Backend/pest_detection/tests.py create mode 100644 Modules/Backend/pest_detection/urls.py create mode 100644 Modules/Backend/pest_detection/views.py create mode 100644 Modules/Backend/plants/__init__.py create mode 100644 Modules/Backend/plants/apps.py create mode 100644 Modules/Backend/plants/migrations/__init__.py create mode 100644 Modules/Backend/plants/models.py create mode 100644 Modules/Backend/plants/serializers.py create mode 100644 Modules/Backend/plants/services.py create mode 100644 Modules/Backend/plants/tests.py create mode 100644 Modules/Backend/plants/urls.py create mode 100644 Modules/Backend/plants/views.py create mode 100644 Modules/Backend/requirements.txt create mode 100644 Modules/Backend/soil/__init__.py create mode 100644 Modules/Backend/soil/apps.py create mode 100644 Modules/Backend/soil/mock_data.py create mode 100644 Modules/Backend/soil/models.py create mode 100644 Modules/Backend/soil/serializers.py create mode 100644 Modules/Backend/soil/services.py create mode 100644 Modules/Backend/soil/tests.py create mode 100644 Modules/Backend/soil/urls.py create mode 100644 Modules/Backend/soil/views.py create mode 100644 Modules/Backend/water/__init__.py create mode 100644 Modules/Backend/water/apps.py create mode 100644 Modules/Backend/water/defaults.py create mode 100644 Modules/Backend/water/migrations/0001_initial.py create mode 100644 Modules/Backend/water/migrations/__init__.py create mode 100644 Modules/Backend/water/mock_data.py create mode 100644 Modules/Backend/water/models.py create mode 100644 Modules/Backend/water/serializers.py create mode 100644 Modules/Backend/water/services.py create mode 100644 Modules/Backend/water/tests.py create mode 100644 Modules/Backend/water/urls.py create mode 100644 Modules/Backend/water/views.py create mode 100644 Modules/Backend/water/weather_urls.py create mode 100644 Modules/Backend/yield_harvest/__init__.py create mode 100644 Modules/Backend/yield_harvest/apps.py create mode 100644 Modules/Backend/yield_harvest/crop_simulation_urls.py create mode 100644 Modules/Backend/yield_harvest/defaults.py create mode 100644 Modules/Backend/yield_harvest/migrations/0001_initial.py create mode 100644 Modules/Backend/yield_harvest/migrations/__init__.py create mode 100644 Modules/Backend/yield_harvest/mock_data.py create mode 100644 Modules/Backend/yield_harvest/models.py create mode 100644 Modules/Backend/yield_harvest/serializers.py create mode 100644 Modules/Backend/yield_harvest/services.py create mode 100644 Modules/Backend/yield_harvest/tests.py create mode 100644 Modules/Backend/yield_harvest/urls.py create mode 100644 Modules/Backend/yield_harvest/views.py create mode 100644 Modules/SensorHub/.cursor/postman.mdc create mode 100644 Modules/SensorHub/.cursor/project.mdc create mode 100644 Modules/SensorHub/.cursor/test-rule.mdc create mode 100644 Modules/SensorHub/.dockerignore create mode 100644 Modules/SensorHub/.env.example create mode 100644 Modules/SensorHub/.gitea/workflows/sensor-hub.yml create mode 100644 Modules/SensorHub/.github/workflows/sensor-hub.yml create mode 100644 Modules/SensorHub/.gitignore create mode 100644 Modules/SensorHub/.gitmodules create mode 100644 Modules/SensorHub/Dockerfile create mode 100644 Modules/SensorHub/Schemas/__init__.py create mode 100644 Modules/SensorHub/Schemas/common.py create mode 100644 Modules/SensorHub/Schemas/crop_simulation_current_farm_chart.py create mode 100644 Modules/SensorHub/Schemas/crop_simulation_growth.py create mode 100644 Modules/SensorHub/Schemas/crop_simulation_growth_status.py create mode 100644 Modules/SensorHub/Schemas/crop_simulation_harvest_prediction.py create mode 100644 Modules/SensorHub/Schemas/crop_simulation_yield_prediction.py create mode 100644 Modules/SensorHub/Schemas/economy_overview.py create mode 100644 Modules/SensorHub/Schemas/farm_alerts.py create mode 100644 Modules/SensorHub/Schemas/farm_data_upsert.py create mode 100644 Modules/SensorHub/Schemas/farm_detail.py create mode 100644 Modules/SensorHub/Schemas/farm_parameter.py create mode 100644 Modules/SensorHub/Schemas/fertilization_recommend.py create mode 100644 Modules/SensorHub/Schemas/irrigation_list.py create mode 100644 Modules/SensorHub/Schemas/irrigation_methods.py create mode 100644 Modules/SensorHub/Schemas/irrigation_recommend.py create mode 100644 Modules/SensorHub/Schemas/irrigation_water_stress.py create mode 100644 Modules/SensorHub/Schemas/pest_disease.py create mode 100644 Modules/SensorHub/Schemas/plant.py create mode 100644 Modules/SensorHub/Schemas/rag_chat.py create mode 100644 Modules/SensorHub/Schemas/soil_data.py create mode 100644 Modules/SensorHub/Schemas/soile_anomaly_detection.py create mode 100644 Modules/SensorHub/Schemas/soile_health_summary.py create mode 100644 Modules/SensorHub/Schemas/soile_moisture_heatmap.py create mode 100644 Modules/SensorHub/Schemas/weather_farm_card.py create mode 100644 Modules/SensorHub/Schemas/weather_water_need_prediction.py create mode 100644 Modules/SensorHub/config/__init__.py create mode 100644 Modules/SensorHub/config/asgi.py create mode 100644 Modules/SensorHub/config/settings.py create mode 100644 Modules/SensorHub/config/urls.py create mode 100644 Modules/SensorHub/config/wsgi.py create mode 100644 Modules/SensorHub/docker-compose-prod.yaml create mode 100644 Modules/SensorHub/docker-compose.yaml create mode 100644 Modules/SensorHub/ingest/__init__.py create mode 100644 Modules/SensorHub/ingest/constants.py create mode 100644 Modules/SensorHub/ingest/management/__init__.py create mode 100644 Modules/SensorHub/ingest/management/commands/__init__.py create mode 100644 Modules/SensorHub/ingest/management/commands/send_sensor_data.py create mode 100644 Modules/SensorHub/ingest/templates/ingest/index.html create mode 100644 Modules/SensorHub/ingest/urls.py create mode 100644 Modules/SensorHub/ingest/views.py create mode 100644 Modules/SensorHub/manage.py create mode 100644 Modules/SensorHub/requirements.txt create mode 100644 PROJECT_WEAKNESSES_AUDIT_FA.md create mode 100644 SENSOR_ARCHITECTURE_RECOMMENDATION.md create mode 160000 SensorHub diff --git a/Accsess b/Accsess new file mode 160000 index 0000000..13b7643 --- /dev/null +++ b/Accsess @@ -0,0 +1 @@ +Subproject commit 13b7643ed3c7fa0fc4c53caa56832d9e344a713d diff --git a/Ai b/Ai new file mode 160000 index 0000000..17628f5 --- /dev/null +++ b/Ai @@ -0,0 +1 @@ +Subproject commit 17628f503fcb9b072b97385f28b5bf8f4983b0b6 diff --git a/BACKEND_PLANTS_AI_FARMDATA_SYNC_PROPOSAL.md b/BACKEND_PLANTS_AI_FARMDATA_SYNC_PROPOSAL.md new file mode 100644 index 0000000..6a4b7d8 --- /dev/null +++ b/BACKEND_PLANTS_AI_FARMDATA_SYNC_PROPOSAL.md @@ -0,0 +1,521 @@ +# پیشنهاد معماری جدید: Backend-owned Plants + AI farm_data Sync + +## خلاصه تصمیم + +تصمیم جدید این است که مالک اصلی داده‌های گیاه در سیستم، `Backend` باشد و اپ `plants` در Backend به‌صورت کامل مسئول نگهداری catalog گیاه‌ها شود. + +در این معماری: + +- اپ `Backend/plants` منبع اصلی `Plant Catalog` خواهد بود +- اپ `Backend/farm_hub` رابطه‌ی فارم با گیاه‌ها را نگه می‌دارد +- اپ `Ai/farm_data` داده‌های مورد نیاز AI را از Backend دریافت می‌کند +- پس از هر تغییر در Backend، داده‌های مرتبط در `Ai/farm_data` به‌روزرسانی می‌شوند +- هرجایی در AI که به اطلاعات گیاه نیاز دارد، از داده‌های `farm_data` استفاده می‌کند + +--- + +## هدف اصلی + +این تغییر برای حل چند مسئله انجام می‌شود: + +- متمرکز شدن catalog گیاه‌ها در Backend +- حذف پراکندگی ownership داده‌های گیاه +- واضح شدن source of truth +- ساده‌تر شدن استفاده‌ی ماژول‌های AI از اطلاعات گیاه +- فراهم شدن یک read-model مشخص در `Ai/farm_data` + +--- + +## تصمیم domain-level + +### 1) Backend مالک اصلی داده‌ی گیاه است + +در این معماری، اطلاعات پایه‌ی گیاه باید در Backend نگهداری شود: + +- نام گیاه +- مشخصات کاتالوگ +- ویژگی‌های رشد +- نور +- آب +- خاک +- دما +- فصل کاشت +- زمان برداشت +- spacing +- fertilizer +- و هر داده‌ی canonical دیگر + +### 2) AI مصرف‌کننده‌ی داده‌ی گیاه است + +در این معماری، AI دیگر owner داده‌های گیاه نیست، بلکه از Backend این اطلاعات را دریافت می‌کند. + +### 3) `Ai/farm_data` لایه‌ی داده‌ای مورد استفاده‌ی AI می‌شود + +در AI، داده‌ی گیاه مستقیماً از مدل canonical مستقل خوانده نمی‌شود، بلکه از داده‌هایی استفاده می‌شود که در `Ai/farm_data` ذخیره یا sync شده‌اند. + +--- + +## ساختار پیشنهادی Backend + +## اپ `plants` + +اپ `Backend/plants` باید به اپ canonical برای catalog گیاه‌ها تبدیل شود. + +### مسئولیت‌ها + +- نگهداری لیست همه‌ی گیاه‌ها +- نگهداری اطلاعات catalog هر گیاه +- ارائه‌ی API برای خواندن catalog گیاه‌ها +- ارائه‌ی API برای دریافت جزئیات یک گیاه +- در صورت نیاز، ارائه‌ی endpointهای تغییرات برای sync با AI + +### داده‌های این app + +نمونه داده‌هایی که بهتر است در `plants` باشند: + +- `id` +- `name` +- `slug` +- `icon` +- `description` +- `light` +- `watering` +- `soil` +- `temperature` +- `growth_stage_defaults` +- `planting_season` +- `harvest_time` +- `spacing` +- `fertilizer` +- `health_profile` +- `irrigation_profile` +- `growth_profile` +- `is_active` +- `updated_at` + +--- + +## اپ `farm_hub` + +در `Backend/farm_hub` باید رابطه‌ی فارم با گیاه نگهداری شود. + +### پیشنهاد رابطه + +در گام اول، چیزی که گفتی منطقی است: + +- یک رابطه‌ی `ManyToMany` بین `FarmHub` و `Plant` + +مثلاً در سطح مفهومی: + +```python +class FarmHub(models.Model): + plants = models.ManyToManyField("plants.Plant", related_name="farms", blank=True) +``` + +این ساختار برای شروع خوب است اگر فقط بخواهی بدانی: + +- هر فارم چه گیاه‌هایی دارد + +### نکته تکمیلی مهم + +اگر بعداً برای رابطه‌ی فارم و گیاه metadata خواستی، `ManyToMany` ساده کافی نیست. + +مثلاً اگر این داده‌ها لازم شوند: + +- تاریخ کاشت +- وضعیت فعلی رشد +- نوع کشت در آن فارم +- تنظیمات اختصاصی آبیاری +- health state +- مقدار هدف تولید + +بهتر است بعداً رابطه به مدل واسط تبدیل شود: + +```python +class FarmPlant(models.Model): + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE) + plant = models.ForeignKey("plants.Plant", on_delete=models.CASCADE) + planted_at = models.DateField(null=True, blank=True) + stage = models.CharField(max_length=64, blank=True) +``` + +### نتیجه + +- برای فاز اول: `ManyToMany` ساده قابل قبول است +- برای فاز matureتر: `through model` بهتر است + +--- + +## ساختار پیشنهادی AI + +## نقش `Ai/farm_data` + +در این تصمیم جدید، `Ai/farm_data` تبدیل به لایه‌ای می‌شود که داده‌های گیاه و داده‌های فارم-گیاه را برای مصرف ماژول‌های AI نگه می‌دارد. + +یعنی: + +- Backend source of truth است +- `Ai/farm_data` read/update replica برای use caseهای AI است + +### وظایف `farm_data` + +- دریافت داده‌ی گیاه از Backend +- دریافت relationهای فارم و گیاه از Backend +- ذخیره‌ی داده‌ی مورد نیاز برای AI +- به‌روزرسانی داده بعد از هر تغییر +- ارائه‌ی data access ساده برای سایر appهای AI + +--- + +## جریان داده پیشنهادی + +### مرحله 1: تغییر در Backend + +هر تغییری که در این بخش‌ها رخ می‌دهد: + +- ایجاد گیاه جدید +- ویرایش catalog گیاه +- حذف یا غیرفعال‌سازی گیاه +- تغییر گیاه‌های مرتبط با یک فارم + +باید باعث شود `Ai/farm_data` نیز به‌روزرسانی شود. + +### مرحله 2: sync به AI + +بعد از تغییر، Backend از طریق API یا مکانیزم sync، داده را به `Ai/farm_data` می‌رساند. + +### مرحله 3: مصرف در AI + +هر app در AI که به داده‌ی گیاه نیاز دارد، به‌جای dependency مستقیم روی مدل‌های قدیمی یا منابع پراکنده، از `farm_data` می‌خواند. + +--- + +## نکته مهم درباره `apps.py` + +اگرچه در پیام قبلی بحث `Ai/farm_data/apps.py` مطرح بود، در این تصمیم جدید بهتر است منطق sync اصلی در `apps.py` قرار نگیرد. + +### `apps.py` برای چه مناسب است؟ + +- app registration +- signal registration +- import سبک startup + +### `apps.py` برای چه مناسب نیست؟ + +- orchestration سنگین sync +- HTTP fetch logic +- retry logic +- reconciliation logic +- business update flow + +### پیشنهاد بهتر + +منطق sync بهتر است در این فایل‌ها قرار بگیرد: + +- `Ai/farm_data/services.py` +- `Ai/farm_data/tasks.py` +- `Ai/farm_data/clients/backend.py` + +و `apps.py` فقط wiring را انجام دهد. + +--- + +## دو نوع داده‌ای که باید تفکیک شوند + +برای اینکه این معماری بعداً تمیز بماند، بهتر است از همین ابتدا دو نوع داده را از هم جدا ببینی. + +### 1) Plant Catalog + +داده‌های عمومی و canonical هر گیاه: + +- نام +- مشخصات رشد +- پروفایل آبیاری +- نیازهای محیطی + +این داده‌ها متعلق به `Backend/plants` هستند. + +### 2) Farm Plant Assignment / Context + +داده‌هایی که مشخص می‌کنند: + +- یک فارم چه گیاهی دارد +- چه گیاهی برای آن فارم فعال است +- چه context یا تنظیمی روی آن اعمال شده + +این داده‌ها از relation بین فارم و گیاه می‌آیند و در Backend تعریف می‌شوند، اما برای مصرف AI در `farm_data` sync می‌شوند. + +--- + +## چه appهایی در AI باید update شوند؟ + +در این معماری جدید، همه appهای AI لزوماً نیاز به تغییر مستقیم ندارند؛ فقط appهایی که الان مستقیم یا غیرمستقیم به داده‌ی گیاه یا `SensorData.plants` وابسته‌اند باید update شوند. + +### 1) `Ai/farm_data` + +این app مهم‌ترین app برای تغییر است و باید به هسته‌ی integration جدید تبدیل شود. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/farm_data/models.py` +- `Ai/farm_data/serializers.py` +- `Ai/farm_data/services.py` +- `Ai/farm_data/views.py` +- `Ai/farm_data/urls.py` +- `Ai/farm_data/apps.py` +- `Ai/farm_data/management/commands/seed_farm_data.py` +- `Ai/farm_data/tests/` + +#### تغییرات مورد نیاز + +- حذف وابستگی مستقیم `ManyToMany` به `plant.Plant` در صورت جایگزینی با replica data +- تعریف ساختار جدید برای نگهداری `plant catalog snapshot` یا `farm plant snapshot` +- افزودن service client برای خواندن data از Backend +- افزودن endpoint یا task برای sync داده از Backend +- تغییر serializerها تا payload جدید گیاه را برگردانند +- اصلاح تست‌ها بر اساس source جدید داده + +#### نکته مهم + +الان در `Ai/farm_data/models.py` فیلد `plants` به `plant.Plant` وصل است. این نقطه یکی از اصلی‌ترین جاهایی است که باید بازطراحی شود، چون در معماری جدید AI نباید برای catalog گیاه به مدل canonical داخلی تکیه کند. + +--- + +### 2) `Ai/rag` + +این app از مهم‌ترین مصرف‌کننده‌های اطلاعات گیاه است و باید update شود تا به‌جای مدل‌های قبلی، از `farm_data` بخواند. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/rag/user_data.py` +- `Ai/rag/services/irrigation.py` +- `Ai/rag/services/fertilization.py` +- `Ai/rag/services/water_need_prediction.py` +- `Ai/rag/services/pest_disease.py` +- `Ai/rag/services/yield_harvest.py` +- `Ai/rag/tests/test_recommendation_services.py` +- هر فایل دیگری که مستقیم `Plant` یا `SensorData.plants` را می‌خواند + +#### تغییرات مورد نیاز + +- حذف import مستقیم از `plant.models` +- استفاده از facade یا service داخل `farm_data` برای دریافت plant context +- جایگزینی readهای مستقیم از مدل گیاه با read model جدید +- اصلاح تست‌ها بر اساس structure جدید داده + +#### نکته مهم + +در حال حاضر `Ai/rag/user_data.py` مستقیم به `Plant` وابستگی دارد. این وابستگی باید حذف شود و همه چیز از `farm_data` خوانده شود. + +--- + +### 3) `Ai/irrigation` + +این app احتمالاً از داده‌ی گیاه برای recommendation یا indicator استفاده می‌کند و باید با read model جدید سازگار شود. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/irrigation/indicators.py` +- `Ai/irrigation/views.py` +- تست‌های `irrigation` + +#### تغییرات مورد نیاز + +- اگر منطق آبیاری به plant profile وابسته است، profile باید از `farm_data` خوانده شود +- حذف هر dependency متنی یا مستقیم به جدول `Plant` +- هماهنگی responseها با data contract جدید + +--- + +### 4) `Ai/soile` + +این app مستقیماً `SensorData` را می‌خواند و در queryها `plants` را prefetch می‌کند؛ بنابراین باید update شود. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/soile/services.py` +- `Ai/soile/test_soil_moisture_heatmap_api.py` + +#### تغییرات مورد نیاز + +- بازنویسی queryهایی که `prefetch_related("plants")` دارند +- استفاده از plant data جدیدی که در `farm_data` ذخیره می‌شود +- اصلاح منطق‌هایی که فرض می‌کنند `plants` یک relation مستقیم ORM به مدل `Plant` است + +--- + +### 5) `Ai/weather` + +این app بیشتر weather-centric است، ولی برخی flowها از `SensorData` و در مواردی از context فارم/گیاه برای پیش‌بینی نیاز آبی استفاده می‌کنند. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/weather/water_need_prediction.py` +- `Ai/weather/farm_weather.py` +- تست‌های مرتبط با farm weather + +#### تغییرات مورد نیاز + +- اگر plant context برای water need استفاده می‌شود، منبع آن باید `farm_data` باشد +- اگر serializer یا service فرض می‌کند relation قبلی `plants` برقرار است، باید اصلاح شود + +--- + +### 6) `Ai/location_data` + +این app بیشتر location-centric است و وابستگی مستقیم شدیدی به گیاه ندارد، اما چون با `SensorData` کار می‌کند باید از سازگاری model جدید مطمئن شویم. + +#### فایل‌ها و بخش‌هایی که باید update شوند + +- `Ai/location_data/ndvi.py` +- `Ai/location_data/views.py` در صورت استفاده از farm context +- تست‌های location مرتبط با farm + +#### تغییرات مورد نیاز + +- بیشتر در حد compatibility check با ساختار جدید `farm_data` +- اگر plant-aware response وجود دارد، باید با source جدید هماهنگ شود + +--- + +### 7) `Ai/farm_alerts` + +اگر alertها به نوع گیاه، stage، یا context گیاه متکی باشند، این app هم باید update شود. + +#### فایل‌ها و بخش‌هایی که باید بررسی/آپدیت شوند + +- `Ai/farm_alerts/services.py` +- `Ai/farm_alerts/alerts_tracker.py` +- `Ai/farm_alerts/views.py` + +#### تغییرات مورد نیاز + +- هرجا alert rule به plant info وابسته است، plant info باید از `farm_data` خوانده شود +- اگر فعلاً وابستگی مستقیم ندارد، فقط compatibility review کافی است + +--- + +### 8) `Ai/economy` + +این app ممکن است در بعضی سناریوها به نوع گیاه برای تحلیل اقتصادی وابسته شود. + +#### فایل‌ها و بخش‌هایی که باید بررسی شوند + +- `Ai/economy/services.py` +- `Ai/economy/views.py` +- تست‌های economy + +#### تغییرات مورد نیاز + +- اگر نوع گیاه یا catalog گیاه در محاسبات اقتصادی استفاده می‌شود، باید از `farm_data` خوانده شود +- اگر فعلاً استفاده‌ای ندارد، تنها review کافی است + +--- + +## appهایی که احتمالاً بیشترین تغییر را دارند + +اگر بخواهیم اولویت‌بندی کنیم، بیشترین تغییر در AI به‌ترتیب در این appها خواهد بود: + +- `Ai/farm_data` +- `Ai/rag` +- `Ai/soile` +- `Ai/irrigation` +- `Ai/weather` + +و این appها بیشتر نیاز به review و compatibility check دارند: + +- `Ai/location_data` +- `Ai/farm_alerts` +- `Ai/economy` + +--- + +## الگوی پیشنهادی برای جلوگیری از پخش شدن وابستگی‌ها + +برای اینکه تغییرات در AI کنترل‌پذیر بماند، بهتر است appهای دیگر مستقیماً model جدید `farm_data` را تکه‌تکه query نزنند. + +### پیشنهاد + +یک لایه‌ی access مرکزی در `farm_data` تعریف شود، مثلاً: + +- `Ai/farm_data/services.py` +- یا `Ai/farm_data/context.py` + +و appهای دیگر فقط از همین interface استفاده کنند. + +### مثال از مسئولیت این facade + +- گرفتن plant catalog برای یک farm +- گرفتن primary plant یا active plants +- گرفتن irrigation profile گیاه +- گرفتن growth/health context گیاه + +این کار باعث می‌شود اگر بعداً schema داخلی `farm_data` عوض شد، فقط یک لایه update شود. + +--- + +## تغییرات مستندی که باید در AI انجام شوند + +علاوه بر کد، این بخش‌های مستنداتی هم باید update شوند: + +- `Ai/API_REFERENCE_FA.md` +- `Ai/APPS_URLS_AUDIT.md` +- `Ai/API_RELIABILITY_AUDIT_FA.md` +- هر doc مرتبط با `Plant API` +- هر doc مرتبط با `farm_data` payload + +### دلیل + +چون در وضعیت جدید: + +- `Plant API` دیگر نباید به‌عنوان canonical داخل AI معرفی شود +- `farm_data` باید به‌عنوان plant consumer/read model معرفی شود +- endpointها و schemaهای response احتمالاً تغییر می‌کنند + +--- + +## پیشنهاد فازبندی update اپ‌های AI + +### فاز 1: هسته‌ی data layer + +- update `Ai/farm_data` +- تعریف schema جدید data +- تعریف sync service با Backend + +### فاز 2: مصرف‌کننده‌های اصلی + +- update `Ai/rag` +- update `Ai/soile` +- update `Ai/irrigation` +- update `Ai/weather` + +### فاز 3: مصرف‌کننده‌های ثانویه + +- review/update `Ai/location_data` +- review/update `Ai/farm_alerts` +- review/update `Ai/economy` + +### فاز 4: پاک‌سازی نهایی + +- حذف dependency مستقیم به `plant.models` +- حذف endpointهای قدیمی یا deprecated در AI +- اصلاح تست‌ها و docs + +--- + +## صورت‌بندی نهایی این تغییر + +نسخه‌ی دقیق‌تر و کامل‌تر این تصمیم به این صورت است: + +- یک app کامل `plants` در Backend نگهدارنده‌ی catalog همه‌ی گیاه‌ها باشد. +- در `Backend/farm_hub` رابطه‌ی `ManyToMany` بین `FarmHub` و `Plant` تعریف شود. +- بعد از هر تغییر در catalog گیاه یا رابطه‌ی فارم-گیاه، داده‌ها به `Ai/farm_data` sync شوند. +- `Ai/farm_data` به منبع مصرفی همه‌ی ماژول‌های AI برای اطلاعات گیاه تبدیل شود. +- در AI هرجا اطلاعات گیاه، catalog گیاه، یا نوع گیاه لازم است، از `farm_data` خوانده شود، نه از مدل‌های پراکنده یا منبع دیگری. +- appهای AI که الان به `Plant` یا `SensorData.plants` وابسته‌اند باید مرحله‌به‌مرحله به این مدل جدید مهاجرت کنند. + +--- + +## نتیجه یک‌خطی + +این تصمیم از نظر معماری قابل دفاع و قابل توسعه است، به شرطی که `Backend/plants` source of truth بماند، `farm_hub` رابطه‌ی فارم-گیاه را نگه دارد، `Ai/farm_data` لایه‌ی sync/read برای AI باشد، و تمام appهای وابسته در AI به‌صورت کنترل‌شده از dependency مستقیم به `Plant` جدا شوند. diff --git a/Backend b/Backend new file mode 160000 index 0000000..35f4d09 --- /dev/null +++ b/Backend @@ -0,0 +1 @@ +Subproject commit 35f4d09225d158e9f178418a58ffe4b90ad495ee diff --git a/Modules/Accsess/README.md b/Modules/Accsess/README.md new file mode 100644 index 0000000..b0664d9 --- /dev/null +++ b/Modules/Accsess/README.md @@ -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`). diff --git a/Modules/Accsess/config/opa-config.DEVELOP.yaml b/Modules/Accsess/config/opa-config.DEVELOP.yaml new file mode 100644 index 0000000..5c0bfb5 --- /dev/null +++ b/Modules/Accsess/config/opa-config.DEVELOP.yaml @@ -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 diff --git a/Modules/Accsess/config/opa-config.default.yaml b/Modules/Accsess/config/opa-config.default.yaml new file mode 100644 index 0000000..6ceb01f --- /dev/null +++ b/Modules/Accsess/config/opa-config.default.yaml @@ -0,0 +1,4 @@ +services: {} +labels: + app: croplogic-authz +plugins: {} diff --git a/Modules/Accsess/config/opa-config.yaml b/Modules/Accsess/config/opa-config.yaml new file mode 100644 index 0000000..6ceb01f --- /dev/null +++ b/Modules/Accsess/config/opa-config.yaml @@ -0,0 +1,4 @@ +services: {} +labels: + app: croplogic-authz +plugins: {} diff --git a/Modules/Accsess/docker-compose.yaml b/Modules/Accsess/docker-compose.yaml new file mode 100644 index 0000000..7b0b08a --- /dev/null +++ b/Modules/Accsess/docker-compose.yaml @@ -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 diff --git a/Modules/Accsess/logs/.gitkeep b/Modules/Accsess/logs/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Accsess/logs/.gitkeep @@ -0,0 +1 @@ + diff --git a/Modules/Accsess/logs/opa.log b/Modules/Accsess/logs/opa.log new file mode 100644 index 0000000..dbf4016 --- /dev/null +++ b/Modules/Accsess/logs/opa.log @@ -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}]} diff --git a/Modules/Accsess/policies/authz.rego b/Modules/Accsess/policies/authz.rego new file mode 100644 index 0000000..cad364e --- /dev/null +++ b/Modules/Accsess/policies/authz.rego @@ -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 +} diff --git a/Modules/Accsess/policies/bootstrap-policies.json b/Modules/Accsess/policies/bootstrap-policies.json new file mode 100644 index 0000000..53fd235 --- /dev/null +++ b/Modules/Accsess/policies/bootstrap-policies.json @@ -0,0 +1,3 @@ +{ + "authz": {} +} diff --git a/Modules/Accsess/policies/sensor_7_in_1.rego b/Modules/Accsess/policies/sensor_7_in_1.rego new file mode 100644 index 0000000..6b654a2 --- /dev/null +++ b/Modules/Accsess/policies/sensor_7_in_1.rego @@ -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])) +} diff --git a/Modules/Accsess/scripts/__pycache__/opa_log_receiver.cpython-314.pyc b/Modules/Accsess/scripts/__pycache__/opa_log_receiver.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c408b487c057bc41810f71db40c0d6e7f8d9d61 GIT binary patch literal 3164 zcmb6bO-viv`Hg4B9(!!_6B59NIBY0cmjx2SHiRYYHh}=U6s8kOd$4yjYfoSjGvl3k z4h@%bE9r%>iUf&PB9&HZdfGkau!o#hIf6{q>Y?hTq9szQp8CD9Uj`*sv#yj0QS;> zqeoE(Ql)03hO@x|zJ`ru<7#B^HwfjABh-enA>fg-(X)YUjA_7i**c}8%71tssBKaZ zi<90z9sN7SO{W7y%Iny`=AuC)IxpLn0oY>~b=R1fomEuh^OE7>3Ej#U42K+7U!5gdIr5U5Krl#OQ3fn^NKcylv)NU8ygDTt=ESN=d*aTj1{>#?o^F2-$t_o z`q(`@q(BKD+SI@~j%oP?rv{3FA7Kfv3d|Bkep55cdXb;wRnfaSDbKdRD_fiCJ`-iK zN}NId+JWW*%tElvmu;--QW|_-2B|?N0X_*uK@DYqyE>F62Kj3T${b@mrG-KM%Csp- zAIBi(r7A(>Aoi~hHJkyjtK)yf_W!&XcCs3o6JCqjJ4~z6$sm7)s0mCKVj%YkMKROG z=*qhc@>hs~Pk)OaO1IC;pbUK?-e6~`Q8jk1c9T;m)5qvOry5s-YTdb7Rr!F^xKg0R zYly|M7@P|#XPBJ7W_*A9eJq2(UlGo(XJ@MouS^3LW*yWYnf_Q!OVM}oKGWf^pFZ{_ zfa9D`VGy&{(=f=e{`xi#)J9*cDxX9rP&Z;ZorjpUAv2Id?I`8_rlvpO+mXqok1WS8 z*%me|+%siZ^LT-9rw`~!%k>ODX60;J5(y&0{IXeG7EAbQ&malYg6d*@u}FlX4w9h- zsGWx665%sDe{*^^%@N7?t?6RTzD_vZ+tzde95pPrdBRkL6afmUWB7VIU%acJ`*k-U+!7mJSJx+F+pIGkpl@Rq&gKn(<_gmWZp zy7pDuS=6yZA1V>@W)9Sv*jNNxuer8G)#`bd1ZQ4p#MvU>6PG zUUEQ~i22fD(Iqk0u<{yMQM4`BATr&tNQg_+_H>8{u2Hy3;<;+9wL&%4vZF6)Mg1l{ zozyd;!2%v)BoXrC+$0io8jUJ+L*Pf(32%_3n{&(}_Ii;)2}i`T6nN`HgGq z*EZ~R`@Xe%?ES}AR^`29YHRrJa3$HjlkDDpZ{$&+oV-*?PVOWp%gIl+rIx?Q$;}(P z@=w0*ewH}C)pxJ&L3cUvv(-=jc4qi?ccrm&r?K~+7F^w%a<)%ZTH%~ zTKH4pkA-sU*-Gn$oz@HG)(`ihjkiZPCf6tTqDSxG&C#u?yHl06-krAIa@*;0^BJIj zeeTZa#>ZcLyt%a7bn2n79~K)TtKy3|3d?KqALKh1H^$e;H)hsnzM3dE^*j`wNEe>P z8t%NidFjEaO054$tpA&Zy=c?g^sVVH2eyXp4c#{$cJ3xWeAM|Qd3iTF{*p)WvtVR6 zR*@1rQeyM$u9Vu7q7|uSM{3!X5&*A7Zbhp5&7z1KLLIZsJ4%{jb8 zSD!b?@wcS;5Sk7>7Z*&+uxw3phNy;*U?N8~4T=FabDEB^V}4e`h70lynwBf*u1nus&L|W#4c>n2 z5E8_w^B;N6IJDDy6OEQjyXn7w561p0&@7&K+0Th}`v|70;gpwJy{Ma(rY(m~_jRuHeICq zU{#VndH6KcASVXAGcSTwle~mT!`0}GI5QwbopXN&(>~8}+z&|p0fk|Hj!r#Cm*6f% z<7-2=hSo-Ijcg}Qf74f%FRn;WLy^_~!|1NEe7+nSS`l6ZlU&_)Yu^h5>;5njqN~fh qeCyNLiTmjXv)dj0+XI)&vGJA5dqSilwCo5i-wTO/` 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. `/`), then base `""` for list. + - Example pattern: + - `path("active/", View.as_view(), kwargs={"action": "active"})` + - `path("deactive/", View.as_view(), kwargs={"action": "deactive"})` + - `path("/", 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: `/postman/.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//...` (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. `/`) 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. diff --git a/Modules/Ai/.cursor/project.mdc b/Modules/Ai/.cursor/project.mdc new file mode 100644 index 0000000..8cc1be1 --- /dev/null +++ b/Modules/Ai/.cursor/project.mdc @@ -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). diff --git a/Modules/Ai/.cursor/test-rule.mdc b/Modules/Ai/.cursor/test-rule.mdc new file mode 100644 index 0000000..6a758c2 --- /dev/null +++ b/Modules/Ai/.cursor/test-rule.mdc @@ -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. diff --git a/Modules/Ai/.dockerignore b/Modules/Ai/.dockerignore new file mode 100644 index 0000000..34fb1e8 --- /dev/null +++ b/Modules/Ai/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +.git +__pycache__ +*.pyc +.venv +venv +*.egg-info +.pytest_cache +.coverage +htmlcov +*.log +media +staticfiles +.cursor diff --git a/Modules/Ai/.env.example b/Modules/Ai/.env.example new file mode 100644 index 0000000..20a00a5 --- /dev/null +++ b/Modules/Ai/.env.example @@ -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 diff --git a/Modules/Ai/.gitea/workflows/ai.yml b/Modules/Ai/.gitea/workflows/ai.yml new file mode 100644 index 0000000..3ab8a3d --- /dev/null +++ b/Modules/Ai/.gitea/workflows/ai.yml @@ -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 \ No newline at end of file diff --git a/Modules/Ai/.github/workflows/ai.yml b/Modules/Ai/.github/workflows/ai.yml new file mode 100644 index 0000000..249555d --- /dev/null +++ b/Modules/Ai/.github/workflows/ai.yml @@ -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 diff --git a/Modules/Ai/.gitignore b/Modules/Ai/.gitignore new file mode 100644 index 0000000..2e0d958 --- /dev/null +++ b/Modules/Ai/.gitignore @@ -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/* diff --git a/Modules/Ai/.gitmodules b/Modules/Ai/.gitmodules new file mode 100644 index 0000000..60b8775 --- /dev/null +++ b/Modules/Ai/.gitmodules @@ -0,0 +1,4 @@ +[submodule "Schemas"] + path = Schemas + url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git + branch = develop diff --git a/Modules/Ai/API_REFERENCE_FA.md b/Modules/Ai/API_REFERENCE_FA.md new file mode 100644 index 0000000..b050d7c --- /dev/null +++ b/Modules/Ai/API_REFERENCE_FA.md @@ -0,0 +1,1533 @@ +# مستند کامل APIهای CropLogic AI + +این فایل مرجع اجرایی APIهای پروژه است و بر اساس کد فعلی `config/urls.py`، `views.py` و `serializers.py` تهیه شده است. + +## نکات کلی + +- پیشوند تمام APIها: `/api/` +- اکثر endpointها خروجی را در envelope زیر برمی‌گردانند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +- بعضی endpointهای AI علاوه بر داده نهایی، این فیلدها را هم برمی‌گردانند: + - `raw_response`: متن خام خروجی مدل + - `knowledge_base`: نام knowledge base مرتبط +- در چند endpoint قدیمی، به‌جای `farm_uuid` می‌توان `sensor_uuid` فرستاد؛ در صورت وجود، backend آن را به `farm_uuid` تبدیل می‌کند. +- فیلدهایی که در این داکیومنت با برچسب `الزامی` آمده‌اند، باید حتما ارسال شوند. + +--- + +## 1) RAG + +### 1.1) چت RAG + +- مسیر: `POST /api/rag/chat/` +- نوع خروجی: `application/json` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `query` — `string` — اختیاری، ولی اگر تصویر نفرستید عملا الزامی است +- `message` — `string` — نام قدیمی `query` +- `history` — `array | string(JSON)` — اختیاری +- `image_urls` — `array` — اختیاری +- `image` — `file` — اختیاری +- `images` — `file[]` — اختیاری + +#### قواعد الزامی + +- اگر `query/message` خالی باشد و هیچ تصویری ارسال نشده باشد، درخواست `400` می‌گیرد +- `farm_uuid` نباید خالی باشد +- `history` باید آرایه باشد یا رشته JSON معتبرِ آرایه + +#### خروجی موفق + +- خروجی این endpoint به‌صورت JSON ساختاریافته برمی‌گردد +- پاسخ موفق داخل `data` شامل آرایه `sections` است + +#### شکل خروجی + +```json +{ + "code": 200, + "msg": "success", + "data": { + "sections": [ + { + "type": "recommendation", + "title": "جمع بندي اصلي", + "icon": "message-circle", + "content": "خلاصه يک جمله اي از بهترين پاسخ يا اقدام اصلي", + "primaryAction": "اقدام اصلي پيشنهادي", + "timing": "بهترين زمان اجرا يا بررسي", + "validityPeriod": "مدت اعتبار اين پاسخ يا توصيه", + "expandableExplanation": "توضيح روشن درباره دليل پاسخ با ارجاع به داده مزرعه، آب و هوا، گياه و شبيه سازي" + }, + { + "type": "list", + "title": "نکات اجرايي", + "icon": "list", + "items": [ + "نکته عملي 1", + "نکته عملي 2" + ] + }, + { + "type": "warning", + "title": "هشدار يا محدوديت", + "icon": "alert-triangle", + "content": "هشدار کوتاه و کاربردي" + } + ] + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر، `farm_uuid` خالی، `history` نامعتبر، `query` خالی +- `404`: `farm` پیدا نشد + +--- + +## 2) Farm Alerts + +### 2.1) Tracker هشدارهای مزرعه + +- مسیر: `POST /api/farm-alerts/tracker/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری، alias +- `query` — `string` — اختیاری + +#### خروجی موفق + +خروجی این endpoint برای نمایش وضعیت هشدارهای مزرعه به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "headline": "string", + "overview": "string", + "status_level": "info | warning | danger", + "notifications": [] + } +} +``` + +`notifications` معمولا رکوردهای ذخیره‌شده هشدار یا هشدارهای نرمال‌شده را برمی‌گرداند. + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `500`: خطا در تولید tracker + +--- + +### 2.2) Timeline هشدارهای مزرعه + +- مسیر: `POST /api/farm-alerts/timeline/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری، alias +- `query` — `string` — اختیاری + +#### خروجی موفق + +خروجی این endpoint برای نمایش timeline هشدارها به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "headline": "string", + "overview": "string", + "timeline": [], + "notifications": [] + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `500`: خطا در تولید timeline + +--- + +## 3) Soil Data + +### 3.1) دریافت داده خاک با GET + +- مسیر: `GET /api/soil-data/?lat=...&lon=...` + +#### ورودی + +- `lat` — `decimal(9,6)` — الزامی +- `lon` — `decimal(9,6)` — الزامی + +#### خروجی موفق از دیتابیس + +```json +{ + "code": 200, + "msg": "success", + "data": { + "source": "database", + "id": 1, + "lon": 51.400000, + "lat": 35.700000, + "depths": [ + { + "depth_label": "0-5cm", + "bdod": null, + "cec": null, + "cfvo": null, + "clay": 22.0, + "nitrogen": 14.0, + "ocd": null, + "ocs": null, + "phh2o": 6.6, + "sand": 40.0, + "silt": 25.0, + "soc": null, + "wv0010": 0.41, + "wv0033": 0.28, + "wv1500": 0.12 + } + ] + } +} +``` + +#### خروجی وقتی داده هنوز آماده نیست + +```json +{ + "code": 202, + "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", + "data": { + "source": "task", + "task_id": "string", + "lon": 51.4, + "lat": 35.7, + "status_url": "/api/soil-data/tasks//status/" + } +} +``` + +#### خطاها + +- `400`: نبودن `lat/lon` یا نامعتبر بودن آن‌ها + +--- + +### 3.2) دریافت داده خاک با POST + +- مسیر: `POST /api/soil-data/` + +#### ورودی + +- `lat` — `decimal(9,6)` — الزامی +- `lon` — `decimal(9,6)` — الزامی + +#### خروجی + +- دقیقا مشابه endpoint قبلی + +--- + +### 3.3) وضعیت تسک داده خاک + +- مسیر: `GET /api/soil-data/tasks//status/` + +#### ورودی + +- `task_id` از path — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "string", + "status": "PENDING | PROGRESS | SUCCESS | FAILURE", + "message": "...", + "progress": {}, + "result": {}, + "error": "..." + } +} +``` + +--- + +### 3.4) NDVI سلامت مزرعه + +- مسیر: `POST /api/soil-data/ndvi-health/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "ndviIndex": 0.73, + "mean_ndvi": 0.73, + "ndvi_map": {}, + "vegetation_health_class": "Healthy", + "observation_date": "2026-04-10", + "satellite_source": "sentinel-2", + "healthData": [ + { + "title": "string", + "value": {}, + "color": "string", + "icon": "string" + } + ] + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه پیدا نشد + +--- + +## 4) Soile + +### 4.1) Heatmap رطوبت خاک + +- مسیر: `POST /api/soile/moisture-heatmap/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "location": {}, + "current_sensor": {}, + "soil_profile": [], + "timestamp": "string | null", + "grid_resolution": {}, + "grid_cells": [], + "sensor_points": [], + "quality_legend": {} + } +} +``` + +خروجی واقعی معمولا علاوه بر موارد بالا شامل `depth_layers`, `model_metadata`, `summary` هم هست. + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه پیدا نشد + +--- + +### 4.2) خلاصه سلامت خاک + +- مسیر: `POST /api/soile/health-summary/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "healthScore": 82, + "profileSource": "Tomato", + "healthScoreDetails": {}, + "healthLanguage": {}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 46.0, + "avgSoilMoistureStatus": "بهینه" + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه پیدا نشد + +--- + +### 4.3) تحلیل ناهنجاری خاک + +- مسیر: `POST /api/soile/anomaly-detection/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +خروجی این endpoint شامل تحلیل ناهنجاری خاک به همراه metadata تکمیلی است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "summary": "جمع بندی کوتاه ناهنجاری", + "explanation": "توضیح کوتاه", + "likely_cause": "علت محتمل", + "recommended_action": "اقدام عملی", + "monitoring_priority": "low | medium | high | urgent", + "confidence": 0.0, + "generated_at": "string", + "anomalies": [], + "interpretation": {}, + "knowledge_base": "string | null", + "raw_response": "string | null" + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه پیدا نشد +- `500`: خطا در تحلیل + +--- + +## 5) Farm Data + +### 5.1) ایجاد/آپدیت farm data + +- مسیر: `POST /api/farm-data/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی +- `farm_boundary` — `object` — الزامی +- `sensor_key` — `string` — اختیاری، پیش‌فرض: `sensor-7-1` +- `sensor_payload` — `object` — اختیاری +- `plant_ids` — `integer[]` — اختیاری +- `irrigation_method_id` — `integer | null` — اختیاری + +#### نکته مهم + +- حداقل یکی از این سه فیلد باید ارسال شود: + - `sensor_payload` + - `plant_ids` + - `irrigation_method_id` + +#### ساختار `farm_boundary` + +دو فرم اصلی در کد پشتیبانی می‌شود: + +1. GeoJSON Polygon + +```json +{ + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.70], + [51.41, 35.70], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.70] + ] + ] +} +``` + +2. corners + +```json +{ + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39} + ] +} +``` + +#### ساختار `sensor_payload` + +```json +{ + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0 + }, + "leaf-sensor": { + "leaf_wetness": 11.0 + } +} +``` + +#### خروجی موفق + +بخش `insight` این endpoint خلاصه تحلیلی و عملیاتی نیاز آبی را برمی‌گرداند: + +```json +{ + "code": 201, + "msg": "success", + "data": { + "farm_uuid": "uuid", + "center_location_id": 1, + "weather_forecast_id": 10, + "sensor_payload": {}, + "plant_ids": [1, 2], + "irrigation_method_id": 3, + "created_at": "datetime", + "updated_at": "datetime" + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر، boundary نامعتبر، `sensor_payload` نامعتبر +- `502`: خطا در sync داده‌های بیرونی خاک/آب‌وهوا + +--- + +### 5.2) جزئیات کامل farm + +- مسیر: `GET /api/farm-data//detail/` + +#### ورودی + +- `farm_uuid` در path — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "center_location": { + "id": 1, + "lat": 35.700000, + "lon": 51.400000, + "farm_boundary": {} + }, + "weather": { + "id": 10, + "forecast_date": "2026-04-10", + "temperature_min": 12.0, + "temperature_max": 23.0, + "temperature_mean": 18.0, + "precipitation": 1.2, + "precipitation_probability": 35.0, + "humidity_mean": 52.0, + "wind_speed_max": 11.0, + "et0": 3.4, + "weather_code": 0 + }, + "sensor_payload": {}, + "soil": { + "resolved_metrics": {}, + "metric_sources": {}, + "depths": [] + }, + "plant_ids": [1, 2], + "plants": [], + "irrigation_method_id": 3, + "irrigation_method": {}, + "created_at": "datetime", + "updated_at": "datetime" + } +} +``` + +#### خطاها + +- `404`: farm یافت نشد + +--- + +### 5.3) تعریف/ویرایش پارامتر سنسور + +- مسیر: `POST /api/farm-data/parameters/` + +#### ورودی + +- `sensor_key` — `string` — اختیاری، پیش‌فرض `sensor-7-1` +- `code` — `string` — الزامی +- `name_fa` — `string` — الزامی +- `unit` — `string` — اختیاری +- `data_type` — `string` — اختیاری، پیش‌فرض `float` +- `metadata` — `object` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 201, + "msg": "success", + "data": { + "id": 1, + "sensor_key": "sensor-7-1", + "code": "soil_moisture", + "name_fa": "رطوبت خاک", + "unit": "%", + "data_type": "float", + "metadata": {}, + "created_at": "datetime", + "action": "added | modified" + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر + +--- + +## 6) Weather + +### 6.1) کارت آب‌وهوای مزرعه + +- مسیر: `POST /api/weather/farm-card/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "condition": "صاف", + "temperature": 28.0, + "unit": "°C", + "humidity": 42.0, + "windSpeed": 15.0, + "windUnit": "km/h", + "chartData": { + "labels": ["2026-04-01", "2026-04-02"], + "series": [[28.0, 29.0]] + } + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه یافت نشد + +--- + +### 6.2) پیش‌بینی نیاز آبی + +- مسیر: `POST /api/weather/water-need-prediction/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "totalNext7Days": 24.6, + "unit": "mm", + "categories": ["روز 1", "روز 2"], + "series": [], + "dailyBreakdown": [], + "insight": { + "summary": "جمع بندی نیاز آبی بازه کوتاه مدت", + "irrigation_outlook": "برداشت عملیاتی از روند آبیاری روزهای آینده", + "recommended_action": "اقدام عملی پیشنهادی برای آبیاری", + "risk_note": "ریسک یا عدم قطعیت مهم", + "confidence": 0.0 + }, + "knowledge_base": "water_need_prediction", + "raw_response": "..." + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: مزرعه یافت نشد +- `500`: خطا در تحلیل + +--- + +## 7) Economy + +### 7.1) نمای اقتصادی مزرعه + +- مسیر: `POST /api/economy/overview/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "source": "mock", + "economicData": [ + { + "title": "string", + "value": "string", + "subtitle": "string", + "avatarIcon": "string", + "avatarColor": "string" + } + ], + "chartSeries": [ + { + "name": "string", + "data": [1.0, 2.0] + } + ], + "chartCategories": ["فروردین", "اردیبهشت"] + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر + +--- + +## 8) Plants + +### 8.1) لیست گیاهان + +- مسیر: `GET /api/plants/` + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم", + "soil": "لومی", + "temperature": "20-30", + "growth_stage": "رویشی", + "planting_season": "بهار", + "harvest_time": "70-90 روز", + "spacing": "45-60 سانتی‌متر", + "fertilizer": "NPK", + "created_at": "datetime", + "updated_at": "datetime" + } + ] +} +``` + +--- + +### 8.2) ایجاد گیاه + +- مسیر: `POST /api/plants/` + +#### ورودی + +- `name` — `string` — الزامی +- `light` — `string` — اختیاری +- `watering` — `string` — اختیاری +- `soil` — `string` — اختیاری +- `temperature` — `string` — اختیاری +- `growth_stage` — `string` — اختیاری +- `planting_season` — `string` — اختیاری +- `harvest_time` — `string` — اختیاری +- `spacing` — `string` — اختیاری +- `fertilizer` — `string` — اختیاری + +#### خروجی موفق + +- همان ساختار `PlantSerializer` +- status: `201` + +#### خطاها + +- `400`: ورودی نامعتبر + +--- + +### 8.3) جزئیات گیاه + +- مسیر: `GET /api/plants//` + +#### خروجی موفق + +- همان `PlantSerializer` + +#### خطاها + +- `404`: گیاه یافت نشد + +--- + +### 8.4) ویرایش کامل گیاه + +- مسیر: `PUT /api/plants//` + +#### ورودی + +- تمام فیلدهای `PlantSerializer` +- عملا `name` باید وجود داشته باشد + +#### خروجی موفق + +- همان `PlantSerializer` + +#### خطاها + +- `400`: ورودی نامعتبر +- `404`: گیاه یافت نشد + +--- + +### 8.5) ویرایش جزئی گیاه + +- مسیر: `PATCH /api/plants//` + +#### ورودی + +- هر زیرمجموعه‌ای از فیلدهای `PlantSerializer` + +#### خروجی موفق + +- همان `PlantSerializer` + +#### خطاها + +- `400`, `404` + +--- + +### 8.6) حذف گیاه + +- مسیر: `DELETE /api/plants//` + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "گیاه با موفقیت حذف شد.", + "data": null +} +``` + +#### خطاها + +- `404`: گیاه یافت نشد + +--- + +### 8.7) دریافت اطلاعات گیاه از API خارجی + +- مسیر: `POST /api/plants/fetch-info/` + +#### ورودی + +- `name` — `string` — الزامی + +#### خروجی موفق + +- object شامل اطلاعات گیاه +- ساختار مورد انتظار مشابه `PlantSerializer` است + +#### خطاها + +- `400`: نام گیاه ارسال نشده +- `503`: سرویس خارجی در دسترس نیست یا پیاده‌سازی نشده + +--- + +## 9) Pest & Disease + +### 9.1) تشخیص آفت/بیماری از روی تصویر + +- مسیر: `POST /api/pest-disease/detect/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری +- `plant_name` — `string` — اختیاری +- `query` — `string` — اختیاری +- `image_urls` — `array` — اختیاری +- `image` — `file` — اختیاری +- `images` — `file[]` — اختیاری + +#### قاعده مهم + +- حداقل یک تصویر باید به یکی از این روش‌ها ارسال شود: `image_urls` یا `image` یا `images` + +#### خروجی موفق + +خروجی این endpoint برای تشخیص آفت/بیماری از روی تصویر به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "has_issue": true, + "category": "pest | disease | nutrient_stress | abiotic_stress | no_issue | unknown", + "confidence": 0.0, + "severity": "low | medium | high", + "summary": "جمع بندی کوتاه تشخیص", + "detected_signs": [], + "possible_causes": [], + "immediate_actions": [], + "reasoning": [] + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` یا نبودن تصویر +- `500`: خطا در تحلیل تصویر + +--- + +### 9.2) پیش‌بینی ریسک آفات و بیماری + +- مسیر: `POST /api/pest-disease/risk/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری +- `plant_name` — `string` — اختیاری +- `growth_stage` — `string` — اختیاری +- `query` — `string` — اختیاری + +#### خروجی موفق + +خروجی این endpoint برای پیش بینی ریسک آفات و بیماری به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "summary": "جمع بندی کوتاه ریسک", + "forecast_window": "بازه زمانی", + "overall_risk": "low | medium | high", + "disease_risk": { + "score": 0.0, + "level": "low | medium | high", + "likely_conditions": [], + "reasoning": [] + }, + "pest_risk": { + "score": 0.0, + "level": "low | medium | high", + "likely_conditions": [], + "reasoning": [] + }, + "key_drivers": [], + "recommended_actions": [] + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `500`: خطا در پیش‌بینی + +--- + +## 10) Irrigation + +### 10.1) لیست روش‌های آبیاری + +- مسیر: `GET /api/irrigation/` + +#### خروجی موفق + +- آرایه‌ای از `IrrigationMethodSerializer` + +#### فیلدهای هر آیتم + +- `id` +- `name` +- `category` +- `description` +- `water_efficiency_percent` +- `water_pressure_required` +- `flow_rate` +- `coverage_area` +- `soil_type` +- `climate_suitability` +- `created_at` +- `updated_at` + +--- + +### 10.2) ایجاد روش آبیاری + +- مسیر: `POST /api/irrigation/` + +#### ورودی + +- `name` — `string` — الزامی +- `category` — `string` — اختیاری +- `description` — `string` — اختیاری +- `water_efficiency_percent` — `float | null` — اختیاری +- `water_pressure_required` — `string` — اختیاری +- `flow_rate` — `string` — اختیاری +- `coverage_area` — `string` — اختیاری +- `soil_type` — `string` — اختیاری +- `climate_suitability` — `string` — اختیاری + +#### خروجی موفق + +- همان `IrrigationMethodSerializer` + +#### خطاها + +- `400`: ورودی نامعتبر + +--- + +### 10.3) جزئیات روش آبیاری + +- مسیر: `GET /api/irrigation//` + +#### خروجی موفق + +- همان `IrrigationMethodSerializer` + +#### خطاها + +- `404`: یافت نشد + +--- + +### 10.4) ویرایش کامل روش آبیاری + +- مسیر: `PUT /api/irrigation//` + +#### ورودی + +- تمام فیلدهای `IrrigationMethodSerializer` + +#### خروجی موفق + +- همان `IrrigationMethodSerializer` + +#### خطاها + +- `400`, `404` + +--- + +### 10.5) ویرایش جزئی روش آبیاری + +- مسیر: `PATCH /api/irrigation//` + +#### ورودی + +- هر زیرمجموعه‌ای از فیلدهای `IrrigationMethodSerializer` + +#### خروجی موفق + +- همان `IrrigationMethodSerializer` + +#### خطاها + +- `400`, `404` + +--- + +### 10.6) حذف روش آبیاری + +- مسیر: `DELETE /api/irrigation//` + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "روش آبیاری با موفقیت حذف شد.", + "data": null +} +``` + +#### خطاها + +- `404`: یافت نشد + +--- + +### 10.7) توصیه آبیاری + +- مسیر: `POST /api/irrigation/recommend/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری +- `plant_name` — `string` — اختیاری +- `growth_stage` — `string` — اختیاری +- `irrigation_method_name` — `string` — اختیاری +- `query` — `string` — اختیاری + +#### خروجی موفق + +این endpoint فقط آبجکت نهایی و قابل استفاده برای کشاورز را برمی‌گرداند و فیلدهای داخلی مثل `simulation_optimizer`، `water_balance`، `mergeMetadata`، `selected_irrigation_method` و `raw_response` در پاسخ public نمایش داده نمی‌شوند. خروجی نهایی به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "sections": [ + { + "type": "recommendation", + "title": "برنامه آبیاری بهینه", + "icon": "droplet", + "content": "خلاصه یک جمله ای از بهترین سناریوی شبیه سازی", + "frequency": "تعداد نوبت آبیاری در بازه اعتبار", + "amount": "مقدار آب در هر نوبت و جمع کل", + "timing": "بهترین زمان اجرا", + "validityPeriod": "مدت اعتبار دقیق توصیه", + "expandableExplanation": "توضیح دلیل انتخاب این سناریو با ارجاع به تنش آبی، دما، بارش و شبیه سازی" + }, + { + "type": "list", + "title": "اقدامات اجرایی", + "icon": "list", + "items": ["نکته عملی 1", "نکته عملی 2"] + }, + { + "type": "warning", + "title": "هشدار آبیاری", + "icon": "alert-triangle", + "content": "هشدار کوتاه و کاربردی" + } + ] + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `500`: خطا در تولید توصیه + +--- + +### 10.8) شاخص تنش آبی + +- مسیر: `POST /api/irrigation/water-stress/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "waterStressIndex": 12, + "level": "پایین", + "sourceMetric": {} + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `404`: مزرعه پیدا نشد + +--- + +## 11) Fertilization + +### 11.1) توصیه کودهی + +- مسیر: `POST /api/fertilization/recommend/` + +#### ورودی + +- `farm_uuid` — `string` — الزامی +- `sensor_uuid` — `string` — اختیاری +- `plant_name` — `string` — اختیاری +- `growth_stage` — `string` — اختیاری +- `query` — `string` — اختیاری + +#### خروجی موفق + +این endpoint فقط خروجی نهایی بهینه شده و آماده استفاده را برمی‌گرداند و جزئیات داخلی مثل `simulation_optimizer`، `mergeMetadata` و `raw_response` را در پاسخ public برنمی‌گرداند. خروجی نهایی به این شکل است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "sections": [ + { + "type": "recommendation", + "title": "برنامه کودهی بهینه", + "icon": "leaf", + "content": "خلاصه یک جمله ای از سناریوی منتخب", + "fertilizerType": "نوع کود پیشنهادی", + "amount": "مقدار مصرف دقیق", + "applicationMethod": "روش مصرف", + "timing": "بهترین زمان اجرا", + "validityPeriod": "مدت اعتبار این توصیه", + "expandableExplanation": "دلیل انتخاب این سناریو بر اساس کمبود عناصر، pH، مرحله رشد و شبیه سازی" + }, + { + "type": "list", + "title": "نکات اجرایی و اختلاط", + "icon": "list", + "items": ["نکته عملی 1", "نکته عملی 2"] + }, + { + "type": "warning", + "title": "هشدار کودهی", + "icon": "alert-triangle", + "content": "هشدار کوتاه و کاربردی" + } + ] + } +} +``` + +#### خطاها + +- `400`: نبودن `farm_uuid` +- `500`: خطا در تولید توصیه + +--- + +## 12) Crop Simulation + +### 12.1) شروع شبیه‌سازی رشد + +- مسیر: `POST /api/crop-simulation/growth/` + +#### ورودی + +- `plant_name` — `string` — الزامی +- `dynamic_parameters` — `string[]` — الزامی و نباید خالی باشد +- `farm_uuid` — `uuid | null` — اختیاری +- `weather` — `object | array` — اختیاری +- `soil_parameters` — `object` — اختیاری +- `site_parameters` — `object` — اختیاری +- `crop_parameters` — `object` — اختیاری +- `agromanagement` — `object` — اختیاری +- `page_size` — `integer` — اختیاری، بین 1 تا 50 + +#### قاعده مهم + +- حداقل یکی از `farm_uuid` یا `weather` باید ارسال شود + +#### خروجی موفق + +```json +{ + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": "string", + "status_url": "/api/crop-simulation/growth//status/", + "plant_name": "گوجه‌فرنگی" + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر + +--- + +### 12.2) وضعیت شبیه‌سازی رشد + +- مسیر: `GET /api/crop-simulation/growth//status/` + +#### query params + +- `page` — `integer` — اختیاری +- `page_size` — `integer` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "string", + "status": "PENDING | PROGRESS | SUCCESS | FAILURE", + "message": "string", + "progress": {}, + "result": { + "plant_name": "string", + "dynamic_parameters": ["DVS", "LAI"], + "engine": "string | null", + "model_name": "string | null", + "scenario_id": 1, + "simulation_warning": "", + "summary_metrics": {}, + "stage_timeline": [], + "stages_page": [], + "pagination": { + "page": 1, + "page_size": 10, + "total_items": 2, + "total_pages": 1, + "has_next": false, + "has_previous": false + }, + "daily_records_count": 51, + "default_page_size": 10 + }, + "error": "string" + } +} +``` + +--- + +### 12.3) chart وضعیت فعلی مزرعه + +- مسیر: `POST /api/crop-simulation/current-farm-chart/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی +- `plant_name` — `string` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string | null", + "plant_name": "string", + "engine": "string | null", + "model_name": "string | null", + "scenario_id": 1, + "simulation_warning": "", + "categories": [], + "series": {}, + "summary": {}, + "current_state": {}, + "metrics": {}, + "daily_output": {} + } +} +``` + +#### خطاها + +- `400`: ورودی نامعتبر +- `500`: خطا در اجرای chart + +--- + +### 12.4) پیش‌بینی برداشت + +- مسیر: `POST /api/crop-simulation/harvest-prediction/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی +- `plant_name` — `string` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "description": "string", + "optimalWindowStart": "2026-07-10", + "optimalWindowEnd": "2026-07-20", + "gddDetails": {} + } +} +``` + +#### خطاها + +- `400`, `500` + +--- + +### 12.5) پیش‌بینی عملکرد + +- مسیر: `POST /api/crop-simulation/yield-prediction/` + +#### ورودی + +- `farm_uuid` — `uuid` — الزامی +- `plant_name` — `string` — اختیاری + +#### خروجی موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "string", + "plant_name": "string | null", + "predictedYieldTons": 8.4, + "predictedYieldRaw": 8400.0, + "unit": "t/ha", + "sourceUnit": "kg/ha", + "simulationEngine": "string | null", + "simulationModel": "string | null", + "scenarioId": 1, + "simulationWarning": "", + "supportingMetrics": {} + } +} +``` + +#### خطاها + +- `400`, `500` + +--- + +## 13) وضعیت کدهای HTTP + +- `200` — موفق +- `201` — ایجاد/آپدیت موفق +- `202` — تسک async در صف قرار گرفت +- `400` — ورودی نامعتبر یا فیلد الزامی ارسال نشده +- `404` — رکورد/مزرعه/گیاه پیدا نشد +- `500` — خطای داخلی در پردازش تحلیلی +- `502` — خطا در sync داده بیرونی +- `503` — سرویس خارجی در دسترس نیست + +--- + +## 14) endpointهای نهایی به‌صورت خلاصه + +- `POST /api/rag/chat/` +- `POST /api/farm-alerts/tracker/` +- `POST /api/farm-alerts/timeline/` +- `GET /api/soil-data/` +- `POST /api/soil-data/` +- `GET /api/soil-data/tasks//status/` +- `POST /api/soil-data/ndvi-health/` +- `POST /api/soile/moisture-heatmap/` +- `POST /api/soile/health-summary/` +- `POST /api/soile/anomaly-detection/` +- `POST /api/farm-data/` +- `GET /api/farm-data//detail/` +- `POST /api/farm-data/parameters/` +- `POST /api/weather/farm-card/` +- `POST /api/weather/water-need-prediction/` +- `POST /api/economy/overview/` +- `GET /api/plants/` +- `POST /api/plants/` +- `GET /api/plants//` +- `PUT /api/plants//` +- `PATCH /api/plants//` +- `DELETE /api/plants//` +- `POST /api/plants/fetch-info/` +- `POST /api/pest-disease/detect/` +- `POST /api/pest-disease/risk/` +- `GET /api/irrigation/` +- `POST /api/irrigation/` +- `GET /api/irrigation//` +- `PUT /api/irrigation//` +- `PATCH /api/irrigation//` +- `DELETE /api/irrigation//` +- `POST /api/irrigation/recommend/` +- `POST /api/irrigation/water-stress/` +- `POST /api/fertilization/recommend/` +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth//status/` +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/harvest-prediction/` +- `POST /api/crop-simulation/yield-prediction/` diff --git a/Modules/Ai/API_RELIABILITY_AUDIT_FA.md b/Modules/Ai/API_RELIABILITY_AUDIT_FA.md new file mode 100644 index 0000000..ef40fa2 --- /dev/null +++ b/Modules/Ai/API_RELIABILITY_AUDIT_FA.md @@ -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 بودن را جداگانه مستند کنید. diff --git a/Modules/Ai/APPS_URLS_AUDIT.md b/Modules/Ai/APPS_URLS_AUDIT.md new file mode 100644 index 0000000..1a17930 --- /dev/null +++ b/Modules/Ai/APPS_URLS_AUDIT.md @@ -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//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//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//` | `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//` | `implemented` | Live route on AI service. | +| `irrigation` | `PUT` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `PATCH` | `/api/irrigation//` | `implemented` | Live route on AI service. | +| `irrigation` | `DELETE` | `/api/irrigation//` | `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//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. diff --git a/Modules/Ai/Dockerfile b/Modules/Ai/Dockerfile new file mode 100644 index 0000000..2106f74 --- /dev/null +++ b/Modules/Ai/Dockerfile @@ -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"] diff --git a/Modules/Ai/Dockerfile.Dev b/Modules/Ai/Dockerfile.Dev new file mode 100644 index 0000000..5e4a332 --- /dev/null +++ b/Modules/Ai/Dockerfile.Dev @@ -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"] diff --git a/Modules/Ai/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md b/Modules/Ai/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md new file mode 100644 index 0000000..bc926d2 --- /dev/null +++ b/Modules/Ai/PCSE_IRRIGATION_FERTILIZATION_GUIDE.md @@ -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` + diff --git a/Modules/Ai/SENSOR_DASHBOARD_API_FA.md b/Modules/Ai/SENSOR_DASHBOARD_API_FA.md new file mode 100644 index 0000000..7aab587 --- /dev/null +++ b/Modules/Ai/SENSOR_DASHBOARD_API_FA.md @@ -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` داشته باشند + diff --git a/Modules/Ai/Schemas/__init__.py b/Modules/Ai/Schemas/__init__.py new file mode 100644 index 0000000..a55565c --- /dev/null +++ b/Modules/Ai/Schemas/__init__.py @@ -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'] diff --git a/Modules/Ai/Schemas/common.py b/Modules/Ai/Schemas/common.py new file mode 100644 index 0000000..8f01345 --- /dev/null +++ b/Modules/Ai/Schemas/common.py @@ -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 diff --git a/Modules/Ai/Schemas/crop_simulation_current_farm_chart.py b/Modules/Ai/Schemas/crop_simulation_current_farm_chart.py new file mode 100644 index 0000000..d43d1dc --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_current_farm_chart.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/crop_simulation_growth.py b/Modules/Ai/Schemas/crop_simulation_growth.py new file mode 100644 index 0000000..642cf07 --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_growth.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/crop_simulation_growth_status.py b/Modules/Ai/Schemas/crop_simulation_growth_status.py new file mode 100644 index 0000000..655d390 --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_growth_status.py @@ -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//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__, +) diff --git a/Modules/Ai/Schemas/crop_simulation_harvest_prediction.py b/Modules/Ai/Schemas/crop_simulation_harvest_prediction.py new file mode 100644 index 0000000..78d363a --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_harvest_prediction.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/crop_simulation_yield_harvest_summary.py b/Modules/Ai/Schemas/crop_simulation_yield_harvest_summary.py new file mode 100644 index 0000000..7cc8213 --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_yield_harvest_summary.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/crop_simulation_yield_prediction.py b/Modules/Ai/Schemas/crop_simulation_yield_prediction.py new file mode 100644 index 0000000..43f0784 --- /dev/null +++ b/Modules/Ai/Schemas/crop_simulation_yield_prediction.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/economy_overview.py b/Modules/Ai/Schemas/economy_overview.py new file mode 100644 index 0000000..362ed56 --- /dev/null +++ b/Modules/Ai/Schemas/economy_overview.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/farm_data_upsert.py b/Modules/Ai/Schemas/farm_data_upsert.py new file mode 100644 index 0000000..ebfe7cd --- /dev/null +++ b/Modules/Ai/Schemas/farm_data_upsert.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/fertilization_recommend.py b/Modules/Ai/Schemas/fertilization_recommend.py new file mode 100644 index 0000000..d4cc072 --- /dev/null +++ b/Modules/Ai/Schemas/fertilization_recommend.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/irrigation_list.py b/Modules/Ai/Schemas/irrigation_list.py new file mode 100644 index 0000000..a16d1d7 --- /dev/null +++ b/Modules/Ai/Schemas/irrigation_list.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/irrigation_recommend.py b/Modules/Ai/Schemas/irrigation_recommend.py new file mode 100644 index 0000000..a8b6653 --- /dev/null +++ b/Modules/Ai/Schemas/irrigation_recommend.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/rag_chat.py b/Modules/Ai/Schemas/rag_chat.py new file mode 100644 index 0000000..a197d0b --- /dev/null +++ b/Modules/Ai/Schemas/rag_chat.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/soile_anomaly_detection.py b/Modules/Ai/Schemas/soile_anomaly_detection.py new file mode 100644 index 0000000..6e4051c --- /dev/null +++ b/Modules/Ai/Schemas/soile_anomaly_detection.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/soile_health_summary.py b/Modules/Ai/Schemas/soile_health_summary.py new file mode 100644 index 0000000..69c2676 --- /dev/null +++ b/Modules/Ai/Schemas/soile_health_summary.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/soile_moisture_heatmap.py b/Modules/Ai/Schemas/soile_moisture_heatmap.py new file mode 100644 index 0000000..b360016 --- /dev/null +++ b/Modules/Ai/Schemas/soile_moisture_heatmap.py @@ -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__, +) diff --git a/Modules/Ai/Schemas/weather_water_need_prediction.py b/Modules/Ai/Schemas/weather_water_need_prediction.py new file mode 100644 index 0000000..2042cff --- /dev/null +++ b/Modules/Ai/Schemas/weather_water_need_prediction.py @@ -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__, +) diff --git a/Modules/Ai/config/__init__.py b/Modules/Ai/config/__init__.py new file mode 100644 index 0000000..726a50f --- /dev/null +++ b/Modules/Ai/config/__init__.py @@ -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",) diff --git a/Modules/Ai/config/asgi.py b/Modules/Ai/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/Modules/Ai/config/asgi.py @@ -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() diff --git a/Modules/Ai/config/celery.py b/Modules/Ai/config/celery.py new file mode 100644 index 0000000..ae7b572 --- /dev/null +++ b/Modules/Ai/config/celery.py @@ -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() diff --git a/Modules/Ai/config/integration_contract.py b/Modules/Ai/config/integration_contract.py new file mode 100644 index 0000000..933879c --- /dev/null +++ b/Modules/Ai/config/integration_contract.py @@ -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 diff --git a/Modules/Ai/config/knowledge_base/.gitkeep b/Modules/Ai/config/knowledge_base/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/config/knowledge_base/README.md b/Modules/Ai/config/knowledge_base/README.md new file mode 100644 index 0000000..5b0c001 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/README.md @@ -0,0 +1,3 @@ +# پایگاه دانش CropLogic + +فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند. diff --git a/Modules/Ai/config/knowledge_base/chat/README.md b/Modules/Ai/config/knowledge_base/chat/README.md new file mode 100644 index 0000000..5b0c001 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/chat/README.md @@ -0,0 +1,3 @@ +# پایگاه دانش CropLogic + +فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند. diff --git a/Modules/Ai/config/knowledge_base/chat/soil_knowledge.txt b/Modules/Ai/config/knowledge_base/chat/soil_knowledge.txt new file mode 100644 index 0000000..cf03622 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/chat/soil_knowledge.txt @@ -0,0 +1,19 @@ +# دانش پایه خاک برای کشاورزی + +## انواع خاک +خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است. + +## pH خاک +مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند. + +## رطوبت خاک +رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود. + +## NPK و عناصر غذایی +نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است. + +## هدایت الکتریکی (EC) +EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است. + +## عمق خاک +داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند. diff --git a/Modules/Ai/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt b/Modules/Ai/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt new file mode 100644 index 0000000..11927c2 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/farm_alerts/farm_alerts_knowledge.txt @@ -0,0 +1,14 @@ +در این پایگاه دانش، هشدارهای مزرعه باید به سه سطح استاندارد تقسیم شوند: +- danger: خطر فوری که به اقدام سریع نیاز دارد. +- warning: هشدار مهم که باید در کوتاه مدت پیگیری شود. +- info: اطلاع رسانی برای پایش، ثبت، یا اقدام کم ریسک. + +قاعده های کلی: +1. اگر تنش می تواند باعث آسیب سریع به گیاه، ریشه، یا عملکرد شود، سطح danger مناسب است. +2. اگر تنش هنوز بحرانی نیست ولی روند آن نگران کننده است، سطح warning مناسب است. +3. اگر فقط برای پایش یا آگاهی اپراتور مفید است، سطح info مناسب است. +4. پیام ها باید کوتاه، اجرایی، و بدون اغراق باشند. +5. اگر داده کافی نیست، باید عدم قطعیت به صراحت بیان شود. +6. در متن نهایی فقط از داده های ساختاریافته مزرعه و هشدارهای محاسبه شده استفاده شود. +7. زمان، شدت، و اقدام پیشنهادی باید با وضعیت واقعی مزرعه همخوان باشد. +8. برای timeline باید ترتیب زمانی رویدادها حفظ شود و هر رویداد توضیح دهد چرا برای مزرعه مهم است. diff --git a/Modules/Ai/config/knowledge_base/fertilization/fertilization_knowledge.txt b/Modules/Ai/config/knowledge_base/fertilization/fertilization_knowledge.txt new file mode 100644 index 0000000..94ea5af --- /dev/null +++ b/Modules/Ai/config/knowledge_base/fertilization/fertilization_knowledge.txt @@ -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 + پایین‌تر (خاک اسیدی)، رشد ریشه متوقف می‌شود. \ No newline at end of file diff --git a/Modules/Ai/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt b/Modules/Ai/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt new file mode 100644 index 0000000..70d9512 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/fertilization_plan_parser/fertilization_plan_parser_knowledge.txt @@ -0,0 +1,28 @@ +راهنمای استخراج برنامه کودهی از متن آزاد + +هدف: +تبدیل توضیح متنی کشاورز درباره برنامه کودهی به JSON ساختاریافته. + +اطلاعات کلیدی که معمولا باید استخراج شوند: +- نام محصول +- مرحله رشد +- هدف مصرف +- نام یا فرمول کود +- مقدار مصرف +- روش مصرف +- زمان مصرف +- فاصله بین نوبت ها +- توضیح تکمیلی یا هشدار + +نمونه عبارت های رایج: +- هر 10 روز یک بار +- بعد از آبیاری +- به صورت کودآبیاری +- سرک +- محلول پاشی +- 35 کیلوگرم در هکتار +- 20-20-20 +- برای تقویت رشد رویشی +- برای شروع گلدهی + +اگر متن ناقص بود، باید فقط سوال های لازم برای تکمیل برنامه نهایی پرسیده شود و از حدس زدن خودداری شود. diff --git a/Modules/Ai/config/knowledge_base/irrigation/irrigation_knowledge.txt b/Modules/Ai/config/knowledge_base/irrigation/irrigation_knowledge.txt new file mode 100644 index 0000000..985d665 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/irrigation/irrigation_knowledge.txt @@ -0,0 +1,26 @@ +بخش اول: راهنمای آبیاری گوجه‌فرنگی (آب‌دهی) +نیاز آبی گوجه‌فرنگی به مرحله رشد، نوع خاک و شرایط آب و هوایی بستگی دارد. مهم‌ترین اصل در آبیاری گوجه‌فرنگی نظم و یکنواختی است. + +۱. مراحل مختلف رشد و نیاز آبی: + +مرحله نشاء و رشد اولیه: خاک باید مرطوب (نه غرقاب) نگه داشته شود تا ریشه‌ها به خوبی مستقر شوند. آبیاری سطحی و مکرر توصیه می‌شود. +مرحله گل‌دهی: تنش آبی در این مرحله باعث ریزش گل‌ها می‌شود. آبیاری باید منظم باشد. +مرحله تشکیل و بزرگ شدن میوه: بیشترین نیاز آبی در این مرحله است. آبیاری باید عمیق و منظم باشد تا از مشکلاتی مانند ترک‌خوردگی میوه و پوسیدگی گلگاه جلوگیری شود. +مرحله رسیدن میوه: با شروع رنگ گرفتن گوجه‌ها، آبیاری را کمی کاهش دهید. این کار باعث افزایش قند، بهبود طعم و جلوگیری از ترک خوردن میوه می‌شود. +۲. نکات کلیدی در آبیاری: + +روش آبیاری: بهترین روش، آبیاری قطره‌ای است. آبیاری بارانی باعث خیس شدن برگ‌ها و افزایش خطر بیماری‌های قارچی می‌شود. +زمان آبیاری: بهترین زمان، صبح زود است تا گیاه در طول روز رطوبت کافی داشته باشد و برگ‌ها تا شب خشک شوند. +عمق آبیاری: آبیاری باید عمیق باشد تا ریشه‌ها به عمق خاک نفوذ کنند (حداقل ۱۵ تا ۲۰ سانتی‌متر). +مالچ‌پاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری می‌کند. + +راهنمای آبیاری هویج +اهمیت رطوبت در هویج: هویج یک گیاه ریشه‌ای است و کیفیت ریشه آن ارتباط مستقیمی با نحوه آبیاری دارد. نوسانات رطوبتی باعث افت شدید کیفیت محصول می‌شود. +نیاز آبی در مراحل مختلف رشد: +کاشت و جوانه‌زنی: بذر هویج بسیار ریز است و در عمق کم کاشته می‌شود. در این مرحله خاک باید دائماً مرطوب (اما نه غرقاب) باشد تا بذرها خشک نشوند. خشکی در این مرحله باعث عدم سبز شدن بذرها می‌شود. +رشد اولیه و توسعه ریشه: پس از سبز شدن، آبیاری باید عمیق‌تر و با فواصل بیشتر انجام شود تا ریشه گیاه برای پیدا کردن آب به عمق خاک نفوذ کند. آبیاری سطحی باعث کوتاه ماندن هویج می‌شود. +حجم گرفتن ریشه (غده‌بندی): نیاز آبی در این مرحله بالاست. رطوبت باید یکنواخت باشد. +نزدیک به برداشت: کاهش آبیاری در اواخر دوره رشد ضروری است. آبیاری زیاد در این مرحله باعث ترک‌خوردگی هویج‌ها می‌شود. +روش‌های آبیاری: +بهترین روش: آبیاری قطره‌ای (نوار تیپ) زیرا رطوبت را به صورت یکنواخت در اختیار ریشه قرار می‌دهد و از بیماری‌های برگی جلوگیری می‌کند. +تنش آبی: خشک و خیس شدن پیاپی خاک، عامل اصلی دو شاخه شدن و ترک خوردن هویج است. diff --git a/Modules/Ai/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt b/Modules/Ai/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt new file mode 100644 index 0000000..0ab5e40 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/irrigation_plan_parser/irrigation_plan_parser_knowledge.txt @@ -0,0 +1,28 @@ +راهنمای استخراج برنامه آبیاری از متن آزاد + +هدف: +تبدیل توضیح متنی کشاورز درباره برنامه آبیاری به JSON ساختاریافته. + +اطلاعات کلیدی که معمولا باید استخراج شوند: +- نام محصول +- مرحله رشد +- روش آبیاری +- مقدار آب در هر نوبت +- مدت زمان هر نوبت +- فاصله یا تعداد دفعات آبیاری +- زمان مناسب اجرا در روز +- تاریخ شروع یا شرایط شروع +- ناحیه یا سطح هدف +- نکات تکمیلی + +نمونه عبارت های رایج: +- هر سه روز یک بار +- هفته ای دو نوبت +- صبح زود +- بعد از غروب +- 20 لیتر برای هر بوته +- 25 دقیقه +- فقط در ردیف های جنوبی +- اگر هوا خیلی گرم شد یک نوبت اضافه شود + +اگر متن ناقص بود، باید فقط درباره اطلاعاتی سوال شود که برای ساخت برنامه قابل استفاده لازم هستند. diff --git a/Modules/Ai/config/knowledge_base/pest_disease/pest_disease_knowledge.txt b/Modules/Ai/config/knowledge_base/pest_disease/pest_disease_knowledge.txt new file mode 100644 index 0000000..4b36bab --- /dev/null +++ b/Modules/Ai/config/knowledge_base/pest_disease/pest_disease_knowledge.txt @@ -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. همیشه خلاصه ای از دلیل نتیجه گیری ارائه بده. diff --git a/Modules/Ai/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt b/Modules/Ai/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt new file mode 100644 index 0000000..e602ff4 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/soil_anomaly/soil_anomaly_knowledge.txt @@ -0,0 +1,23 @@ +تحليل ناهنجاري خاک و سنسور + +هدف اين دانشنامه کمک به تفسير ناهنجاري هاي آماري در داده هاي خاک و سنسور مزرعه است. + +اصول کلي: +- ناهنجاري آماري به معناي مشکل قطعي مزرعه نيست؛ اول بايد پايداري رخداد، شدت انحراف، و سازگاري آن با ساير شاخص ها بررسي شود. +- وقتي رطوبت خاک و دماي خاک همزمان ناهنجار مي شوند، احتمال تنش ريشه، آبياري نامناسب، يا موج گرما بيشتر است. +- وقتي EC و رطوبت خاک با هم ناهنجار شوند، فشار شوري، تجمع نمک، کيفيت نامناسب آب يا برنامه کوددهي نامتوازن بايد بررسي شود. +- اگر pH از محدوده معمول مزرعه فاصله بگيرد، دسترسي عناصر غذايي و کارايي جذب ريشه مي تواند تحت تاثير قرار بگيرد. +- ناهنجاري رطوبت هوا در کنار دما و رطوبت خاک مي تواند نشانه شرايط مساعد براي بيماري يا افزايش تبخير-تعرق باشد. + +راهنماي تفسير شدت: +- low: انحراف خفيف يا کوتاه مدت؛ معمولا نياز به پايش دارد. +- medium: انحراف قابل توجه؛ بايد با شرايط مزرعه و آبياري تطبيق داده شود. +- high: انحراف مهم؛ بازبيني سريع سنسور و عمليات مزرعه لازم است. +- critical: رخداد شديد يا پرتکرار؛ نياز به اقدام فوري و بررسي ميداني دارد. + +اقدامات پيشنهادي عمومي: +- وضعيت آخرين آبياري، زمان بندي و يکنواختي توزيع آب بررسي شود. +- کاليبراسيون سنسور و سلامت سخت افزاري آن در رخدادهاي ناگهاني کنترل شود. +- تغييرات اخير در کوددهي، شوري آب، بارش موثر و دماي محيط در تحليل لحاظ شود. +- اگر ناهنجاري در چند شاخص همزمان ديده شد، اولويت پايش و مداخله بالاتر در نظر گرفته شود. +- اگر ناهنجاري در داده هاي محدود يا ناقص ديده شد، قبل از توصيه قطعي کمبود داده صريح گفته شود. diff --git a/Modules/Ai/config/knowledge_base/soil_knowledge.txt b/Modules/Ai/config/knowledge_base/soil_knowledge.txt new file mode 100644 index 0000000..cf03622 --- /dev/null +++ b/Modules/Ai/config/knowledge_base/soil_knowledge.txt @@ -0,0 +1,19 @@ +# دانش پایه خاک برای کشاورزی + +## انواع خاک +خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است. + +## pH خاک +مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند. + +## رطوبت خاک +رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود. + +## NPK و عناصر غذایی +نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است. + +## هدایت الکتریکی (EC) +EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است. + +## عمق خاک +داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند. diff --git a/Modules/Ai/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt b/Modules/Ai/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt new file mode 100644 index 0000000..413be5e --- /dev/null +++ b/Modules/Ai/config/knowledge_base/water_need_prediction/water_need_prediction_knowledge.txt @@ -0,0 +1,17 @@ +تحليل نياز آبي کوتاه مدت مزرعه + +اين دانشنامه براي تفسير خروجي محاسبات نياز آبي روزهاي آينده استفاده مي شود. + +اصول کلي: +- `et0` تبخير-تعرق مرجع است و نشان مي دهد شرايط اقليمي هر روز چه ميزان تقاضاي تبخير-تعرق ايجاد مي کند. +- `etc` از ضرب `et0` در ضريب گياهي `kc` به دست مي آيد و تخمين مناسب تري از نياز آبي محصول مي دهد. +- `effective_rainfall` بخشي از بارش است که واقعا در تامين نياز آبي گياه موثر واقع مي شود. +- `net_irrigation_mm` نياز آبي خالص پس از کسر بارش موثر است. +- `gross_irrigation_mm` نياز آبي واقعي اجرايي با درنظر گرفتن راندمان سامانه آبياري است. + +راهنماي تفسير: +- اگر `gross_irrigation_mm` در چند روز پياپي بالا باشد، برنامه آبياري بايد فشرده تر و منظم تر تنظيم شود. +- اگر راندمان آبياري پايين باشد، اختلاف بين نياز خالص و ناخالص بيشتر مي شود و اتلاف آب بالاتر است. +- در روزهاي گرم، پر باد يا کم بارش، بهتر است اجراي آبياري به صبح زود يا نزديک غروب منتقل شود. +- اگر بارش موثر پيش بيني شده باشد، بخشي از نياز آبي مي تواند بدون آبياري اضافي تامين شود. +- توصيه ها بايد عملياتي، کوتاه مدت، و همسو با forecast فعلي باشند و در صورت عدم قطعيت، آن را صريح بيان کنند. diff --git a/Modules/Ai/config/openapi.py b/Modules/Ai/config/openapi.py new file mode 100644 index 0000000..5d913f3 --- /dev/null +++ b/Modules/Ai/config/openapi.py @@ -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) diff --git a/Modules/Ai/config/rag_config.yaml b/Modules/Ai/config/rag_config.yaml new file mode 100644 index 0000000..4d94292 --- /dev/null +++ b/Modules/Ai/config/rag_config.yaml @@ -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" diff --git a/Modules/Ai/config/settings.py b/Modules/Ai/config/settings.py new file mode 100644 index 0000000..c6f73b5 --- /dev/null +++ b/Modules/Ai/config/settings.py @@ -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") diff --git a/Modules/Ai/config/settings_test.py b/Modules/Ai/config/settings_test.py new file mode 100644 index 0000000..2cfc573 --- /dev/null +++ b/Modules/Ai/config/settings_test.py @@ -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": [], +} diff --git a/Modules/Ai/config/test_urls.py b/Modules/Ai/config/test_urls.py new file mode 100644 index 0000000..8bdb1b8 --- /dev/null +++ b/Modules/Ai/config/test_urls.py @@ -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")), +] diff --git a/Modules/Ai/config/tone.txt b/Modules/Ai/config/tone.txt new file mode 100644 index 0000000..5471eac --- /dev/null +++ b/Modules/Ai/config/tone.txt @@ -0,0 +1,7 @@ +# فایل لحن / سبک پاسخ‌های RAG + +لحن و سبک پاسخ‌ها: +- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن. +- واژگان: از اصطلاحات رایج کشاورزی و خاک‌شناسی استفاده کن، در صورت نیاز معادل فارسی بیاور. +- طول: پاسخ‌ها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن. +- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده. diff --git a/Modules/Ai/config/tones/chat_tone.txt b/Modules/Ai/config/tones/chat_tone.txt new file mode 100644 index 0000000..12f21ad --- /dev/null +++ b/Modules/Ai/config/tones/chat_tone.txt @@ -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. diff --git a/Modules/Ai/config/tones/farm_alerts_tone.txt b/Modules/Ai/config/tones/farm_alerts_tone.txt new file mode 100644 index 0000000..c76c46b --- /dev/null +++ b/Modules/Ai/config/tones/farm_alerts_tone.txt @@ -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 حداقلي فقط وقتي استفاده شود که خروجي در غير اين صورت خالي مي شد. diff --git a/Modules/Ai/config/tones/fertilization_plan_parser_tone.txt b/Modules/Ai/config/tones/fertilization_plan_parser_tone.txt new file mode 100644 index 0000000..a2905f3 --- /dev/null +++ b/Modules/Ai/config/tones/fertilization_plan_parser_tone.txt @@ -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." +- "مقدار مصرف هر نوبت کود چقدر است؟" +- "فاصله بین نوبت های مصرف کود چند روز است؟" diff --git a/Modules/Ai/config/tones/fertilization_tone.txt b/Modules/Ai/config/tones/fertilization_tone.txt new file mode 100644 index 0000000..c17b11f --- /dev/null +++ b/Modules/Ai/config/tones/fertilization_tone.txt @@ -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. diff --git a/Modules/Ai/config/tones/irrigation_plan_parser_tone.txt b/Modules/Ai/config/tones/irrigation_plan_parser_tone.txt new file mode 100644 index 0000000..6be19e1 --- /dev/null +++ b/Modules/Ai/config/tones/irrigation_plan_parser_tone.txt @@ -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` + +نمونه سوال خوب: +- "محصول الان در چه مرحله رشدی قرار دارد؟" +- "این برنامه از چه تاریخی باید شروع شود؟" +- "این برنامه برای کل مزرعه است یا فقط یک بخش خاص؟" diff --git a/Modules/Ai/config/tones/irrigation_tone.txt b/Modules/Ai/config/tones/irrigation_tone.txt new file mode 100644 index 0000000..a8a8ddd --- /dev/null +++ b/Modules/Ai/config/tones/irrigation_tone.txt @@ -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. diff --git a/Modules/Ai/config/tones/pest_disease_tone.txt b/Modules/Ai/config/tones/pest_disease_tone.txt new file mode 100644 index 0000000..4c524d4 --- /dev/null +++ b/Modules/Ai/config/tones/pest_disease_tone.txt @@ -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` بايد عملي، کوتاه و قابل اجرا باشند. diff --git a/Modules/Ai/config/tones/soil_anomaly_tone.txt b/Modules/Ai/config/tones/soil_anomaly_tone.txt new file mode 100644 index 0000000..dcb45ce --- /dev/null +++ b/Modules/Ai/config/tones/soil_anomaly_tone.txt @@ -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` بايد اين موضوع را واضح بگويند. diff --git a/Modules/Ai/config/tones/water_need_prediction_tone.txt b/Modules/Ai/config/tones/water_need_prediction_tone.txt new file mode 100644 index 0000000..2928044 --- /dev/null +++ b/Modules/Ai/config/tones/water_need_prediction_tone.txt @@ -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` بگو. diff --git a/Modules/Ai/config/tones/yield_harvest_tone.txt b/Modules/Ai/config/tones/yield_harvest_tone.txt new file mode 100644 index 0000000..f6cf893 --- /dev/null +++ b/Modules/Ai/config/tones/yield_harvest_tone.txt @@ -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 diff --git a/Modules/Ai/config/urls.py b/Modules/Ai/config/urls.py new file mode 100644 index 0000000..d1b3561 --- /dev/null +++ b/Modules/Ai/config/urls.py @@ -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")), +] diff --git a/Modules/Ai/config/user_info/.gitkeep b/Modules/Ai/config/user_info/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/config/wsgi.py b/Modules/Ai/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/Modules/Ai/config/wsgi.py @@ -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() diff --git a/Modules/Ai/constraints.txt b/Modules/Ai/constraints.txt new file mode 100644 index 0000000..a2f5bdb --- /dev/null +++ b/Modules/Ai/constraints.txt @@ -0,0 +1,2 @@ +# Keep build-isolated dependency resolution compatible with Python 3.10. +numpy>=1.23,<1.27 diff --git a/Modules/Ai/crop_simulation/SERVICES_INTEGRATION.md b/Modules/Ai/crop_simulation/SERVICES_INTEGRATION.md new file mode 100644 index 0000000..16f0317 --- /dev/null +++ b/Modules/Ai/crop_simulation/SERVICES_INTEGRATION.md @@ -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 نشان بدهد. diff --git a/Modules/Ai/crop_simulation/__init__.py b/Modules/Ai/crop_simulation/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/crop_simulation/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/crop_simulation/apps.py b/Modules/Ai/crop_simulation/apps.py new file mode 100644 index 0000000..b00fa43 --- /dev/null +++ b/Modules/Ai/crop_simulation/apps.py @@ -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 diff --git a/Modules/Ai/crop_simulation/growth_simulation.py b/Modules/Ai/crop_simulation/growth_simulation.py new file mode 100644 index 0000000..b05b7e0 --- /dev/null +++ b/Modules/Ai/crop_simulation/growth_simulation.py @@ -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, + ) diff --git a/Modules/Ai/crop_simulation/harvest_prediction.py b/Modules/Ai/crop_simulation/harvest_prediction.py new file mode 100644 index 0000000..155deb5 --- /dev/null +++ b/Modules/Ai/crop_simulation/harvest_prediction.py @@ -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, + ) diff --git a/Modules/Ai/crop_simulation/migrations/0001_initial.py b/Modules/Ai/crop_simulation/migrations/0001_initial.py new file mode 100644 index 0000000..0fe3a2b --- /dev/null +++ b/Modules/Ai/crop_simulation/migrations/0001_initial.py @@ -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", + ), + ), + ] diff --git a/Modules/Ai/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py b/Modules/Ai/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py new file mode 100644 index 0000000..1c1034e --- /dev/null +++ b/Modules/Ai/crop_simulation/migrations/0002_alter_simulationscenario_model_name.py @@ -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), + ), + ] diff --git a/Modules/Ai/crop_simulation/migrations/__init__.py b/Modules/Ai/crop_simulation/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/crop_simulation/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/crop_simulation/models.py b/Modules/Ai/crop_simulation/models.py new file mode 100644 index 0000000..c556a71 --- /dev/null +++ b/Modules/Ai/crop_simulation/models.py @@ -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}" diff --git a/Modules/Ai/crop_simulation/recommendation_optimizer.py b/Modules/Ai/crop_simulation/recommendation_optimizer.py new file mode 100644 index 0000000..031ba5c --- /dev/null +++ b/Modules/Ai/crop_simulation/recommendation_optimizer.py @@ -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 diff --git a/Modules/Ai/crop_simulation/serializers.py b/Modules/Ai/crop_simulation/serializers.py new file mode 100644 index 0000000..e33e808 --- /dev/null +++ b/Modules/Ai/crop_simulation/serializers.py @@ -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() diff --git a/Modules/Ai/crop_simulation/services.py b/Modules/Ai/crop_simulation/services.py new file mode 100644 index 0000000..1b8d7e2 --- /dev/null +++ b/Modules/Ai/crop_simulation/services.py @@ -0,0 +1,1359 @@ +from __future__ import annotations + +import importlib +from copy import deepcopy +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from typing import Any + +from django.db import transaction +from location_data.satellite_snapshot import build_location_satellite_snapshot + +from .models import SimulationRun, SimulationScenario + + +DEFAULT_OUTPUT_VARS = ["DVS", "LAI", "TAGP", "TWSO", "SM"] +DEFAULT_SUMMARY_VARS = ["TAGP", "TWSO", "CTRAT", "RD"] +DEFAULT_TERMINAL_VARS = ["TAGP", "TWSO", "LAI", "DVS"] +DEFAULT_PCSE_MODEL_NAME = "Wofost81_NWLP_CWB_CNB" +DEFAULT_NAVAILI = 35.0 +DEFAULT_WAV = 40.0 + + +class CropSimulationError(Exception): + pass + + +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 _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 CropSimulationError(f"Unsupported date value: {value!r}") + + +def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]: + if isinstance(weather, dict): + if "records" in weather: + records = weather["records"] + else: + records = [weather] + else: + records = weather + + if not isinstance(records, list) or not records: + raise CropSimulationError("Weather input must contain at least one record.") + + normalized = [] + for raw in records: + if not isinstance(raw, dict): + raise CropSimulationError("Weather records must be dictionaries.") + current_date = _coerce_date(raw.get("DAY") or raw.get("day")) + normalized.append( + { + "DAY": current_date, + "LAT": float(raw.get("LAT", raw.get("lat", 0.0))), + "LON": float(raw.get("LON", raw.get("lon", 0.0))), + "ELEV": float(raw.get("ELEV", raw.get("elev", 0.0))), + "IRRAD": float(raw.get("IRRAD", raw.get("irrad", 15_000_000.0))), + "TMIN": float(raw.get("TMIN", raw.get("tmin", 10.0))), + "TMAX": float(raw.get("TMAX", raw.get("tmax", 20.0))), + "VAP": float(raw.get("VAP", raw.get("vap", 12.0))), + "WIND": float(raw.get("WIND", raw.get("wind", 2.0))), + "RAIN": float(raw.get("RAIN", raw.get("rain", 0.0))), + "E0": float(raw.get("E0", raw.get("e0", 0.35))), + "ES0": float(raw.get("ES0", raw.get("es0", 0.3))), + "ET0": float(raw.get("ET0", raw.get("et0", 0.32))), + } + ) + return normalized + + +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 _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]: + if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement: + campaigns = agromanagement["AgroManagement"] + elif isinstance(agromanagement, list): + campaigns = agromanagement + elif isinstance(agromanagement, dict): + campaigns = [agromanagement] + else: + raise CropSimulationError("Agromanagement input must be a dict or list.") + + if not campaigns: + raise CropSimulationError("Agromanagement input cannot be empty.") + return _ensure_trailing_empty_campaign(campaigns) + + +def _ensure_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> list[dict[str, Any]]: + normalized = list(campaigns) + if not normalized: + return normalized + last_campaign = normalized[-1] + if _is_explicit_empty_campaign(last_campaign): + return normalized + + trailing = _build_trailing_empty_campaign(normalized) + if last_campaign == {}: + normalized[-1] = trailing + else: + normalized.append(trailing) + return normalized + + +def _is_explicit_empty_campaign(campaign: dict[str, Any]) -> bool: + if not isinstance(campaign, dict) or len(campaign) != 1: + return False + start_date, payload = next(iter(campaign.items())) + return isinstance(start_date, date) and payload is None + + +def _build_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> dict[date, None]: + last_campaign = next((item for item in reversed(campaigns) if isinstance(item, dict) and item), None) + if not last_campaign: + return {date.today(): None} + + campaign_start, campaign_payload = next(iter(last_campaign.items())) + candidate_dates = [_coerce_date(campaign_start)] + + if isinstance(campaign_payload, dict): + crop_calendar = campaign_payload.get("CropCalendar") or {} + for field_name in ("crop_end_date", "crop_start_date"): + value = crop_calendar.get(field_name) + if value: + candidate_dates.append(_coerce_date(value)) + + for bucket_name in ("TimedEvents",): + for event_group in campaign_payload.get(bucket_name, []) or []: + if not isinstance(event_group, dict): + continue + for event in event_group.get("events_table", []) or []: + if not isinstance(event, dict) or not event: + continue + event_date = next(iter(event.keys())) + candidate_dates.append(_coerce_date(event_date)) + + return {max(candidate_dates) + timedelta(days=1): None} + + +def _deep_copy_json_like(value: Any) -> Any: + if isinstance(value, dict): + return {key: _deep_copy_json_like(item) for key, item in value.items()} + if isinstance(value, list): + return [_deep_copy_json_like(item) for item in value] + return value + + +def _parse_recommendation_events( + recommendation: dict[str, Any] | None, + *, + event_signal: str, + amount_keys: tuple[str, ...], + extra_keys: tuple[str, ...], +) -> list[dict[str, Any]]: + if not recommendation: + return [] + + raw_events = recommendation.get("events") + if raw_events is None: + raw_events = recommendation.get("schedule") + if raw_events is None: + raw_events = recommendation.get("applications") + if raw_events is None: + raw_events = recommendation.get("plan") + + if not isinstance(raw_events, list): + return [] + + events_table = [] + for item in raw_events: + if not isinstance(item, dict): + continue + raw_date = item.get("date") or item.get("day") + if raw_date is None: + continue + payload = {} + amount_value = None + amount_key = None + for candidate in amount_keys: + if item.get(candidate) is not None: + amount_value = item.get(candidate) + amount_key = candidate + break + if amount_key is not None: + payload[amount_key] = float(amount_value) + for extra_key in extra_keys: + if item.get(extra_key) is not None: + payload[extra_key] = float(item[extra_key]) + if payload: + events_table.append({_coerce_date(raw_date): payload}) + + if not events_table: + return [] + + return [ + { + "event_signal": event_signal, + "name": recommendation.get("name", f"{event_signal} recommendation"), + "comment": recommendation.get("comment", ""), + "events_table": events_table, + } + ] + + +def _merge_management_recommendations( + agromanagement: Any, + *, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + campaigns = _deep_copy_json_like(_normalize_agromanagement(agromanagement)) + + irrigation_events = _parse_recommendation_events( + irrigation_recommendation, + event_signal="irrigate", + amount_keys=("amount", "irrigation_amount"), + extra_keys=("efficiency",), + ) + fertilization_events = _parse_recommendation_events( + fertilization_recommendation, + event_signal="apply_n", + amount_keys=("N_amount", "amount"), + extra_keys=("N_recovery",), + ) + + if not irrigation_events and not fertilization_events: + return campaigns + + target_campaign = None + for campaign in campaigns: + if isinstance(campaign, dict) and campaign: + target_campaign = campaign + break + + if target_campaign is None: + raise CropSimulationError( + "Agromanagement must contain at least one non-empty campaign." + ) + + campaign_start = next(iter(target_campaign.keys())) + campaign_payload = target_campaign[campaign_start] + if not isinstance(campaign_payload, dict): + raise CropSimulationError("Agromanagement campaign payload must be a dictionary.") + + timed_events = campaign_payload.get("TimedEvents") + if timed_events in (None, ""): + timed_events = [] + if not isinstance(timed_events, list): + raise CropSimulationError("TimedEvents must be a list when recommendations are merged.") + + timed_events.extend(irrigation_events) + timed_events.extend(fertilization_events) + campaign_payload["TimedEvents"] = timed_events + return campaigns + + +def _normalize_pcse_output_records(records: Any) -> list[dict[str, Any]]: + if records is None: + return [] + if isinstance(records, dict): + return [records] + if isinstance(records, list): + return records + if isinstance(records, tuple): + return list(records) + return [records] + + +def _pick_first_not_none(*values: Any) -> Any: + for value in values: + if value is not None: + return value + return None + + +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 _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(value, upper)) + + +def _sensor_metric(sensor: Any, metric_name: str) -> float | None: + if sensor is None: + return None + + if hasattr(sensor, metric_name): + value = getattr(sensor, metric_name) + if value is not None: + return _safe_float(value) + + 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_name) is not None: + return _safe_float(block.get(metric_name)) + return None + + +def _extract_plant_simulation_profile(plant: Any | None) -> dict[str, Any] | None: + if plant is None: + return 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 isinstance(simulation, dict): + return simulation + return None + + +def _build_default_crop_parameters(plant: Any | None, crop_name: str) -> dict[str, Any]: + profile = getattr(plant, "growth_profile", None) or {} + required_gdd = _safe_float(profile.get("required_gdd_for_maturity"), 1200.0) + return { + "crop_name": crop_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(crop_name: str, weather: list[dict[str, Any]]) -> list[dict[str, Any]]: + first_day = weather[0]["DAY"] + last_day = weather[-1]["DAY"] + crop_end = max(last_day, first_day + (last_day - first_day)) + return _ensure_trailing_empty_campaign([ + { + first_day: { + "CropCalendar": { + "crop_name": crop_name, + "variety_name": "default", + "crop_start_date": first_day, + "crop_start_type": "sowing", + "crop_end_date": crop_end, + "crop_end_type": "harvest", + "max_duration": max((crop_end - first_day).days, 1), + }, + "TimedEvents": [], + "StateEvents": [], + } + } + ]) + + +def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]: + return [ + { + "DAY": forecast.forecast_date, + "LAT": latitude, + "LON": longitude, + "ELEV": 1200.0, + "IRRAD": 16_000_000.0, + "TMIN": _safe_float( + _pick_first_not_none(forecast.temperature_min, forecast.temperature_mean), + 12.0, + ), + "TMAX": _safe_float( + _pick_first_not_none(forecast.temperature_max, forecast.temperature_mean), + 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), + } + for forecast in forecasts + ] + + +def _normalize_site_parameters_for_model( + model_name: str, + site_parameters: dict[str, Any] | None, + *, + soil_parameters: dict[str, Any] | None = None, +) -> dict[str, Any]: + site = dict(site_parameters or {}) + soil = soil_parameters or {} + + site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV)) + smw = _safe_float(soil.get("SMW"), 0.14) + smfcf = _safe_float(soil.get("SMFCF"), 0.34) + sm0 = _safe_float( + _pick_first_not_none(soil.get("SM0"), soil.get("SMMAX")), + min(max(smfcf + 0.08, smw + 0.12), 0.6), + ) + site.setdefault("IFUNRN", 0) + site.setdefault("NOTINF", 0.0) + site.setdefault("SSI", 0.0) + site.setdefault("SSMAX", 0.0) + site.setdefault("SMLIM", round(_clamp(_safe_float(site.get("SMLIM"), smfcf), smw, sm0), 3)) + if model_name.startswith("Wofost81_NWLP"): + navaili = _pick_first_not_none( + site.get("NAVAILI"), + site.get("navaili"), + site.get("nitrogen"), + soil.get("NAVAILI"), + soil.get("nitrogen"), + ) + site["NAVAILI"] = _safe_float(navaili, DEFAULT_NAVAILI) + site.setdefault("BG_N_SUPPLY", 0.05) + site.setdefault("NSOILBASE", max(site["NAVAILI"] * 0.35, 5.0)) + site.setdefault("NSOILBASE_FR", 0.02) + return site + + +def build_simulation_payload_from_farm( + *, + farm_uuid: str, + plant_name: str | None = None, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crop_parameters: dict[str, Any] | None = None, + agromanagement: Any | None = None, + site_parameters: dict[str, Any] | None = None, +) -> dict[str, Any]: + from farm_data.services import ( + get_canonical_farm_record, + get_runtime_plant_for_farm, + list_runtime_plants_for_farm, + ) + from weather.models import WeatherForecast + + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.") + + plant = get_runtime_plant_for_farm(farm, plant_name=plant_name) + + if weather is not None: + resolved_weather = _normalize_weather_records(weather) + else: + forecasts = list( + WeatherForecast.objects.filter(location=farm.center_location) + .order_by("forecast_date")[:14] + ) + if not forecasts: + raise CropSimulationError( + "Weather data for the selected farm is missing." + ) + resolved_weather = _build_weather_from_forecasts( + forecasts, + latitude=float(farm.center_location.latitude), + longitude=float(farm.center_location.longitude), + ) + + satellite_metrics = build_location_satellite_snapshot(farm.center_location).get("resolved_metrics") or {} + ndwi = _safe_float(satellite_metrics.get("ndwi"), 0.28) + smfcf = _clamp(ndwi if ndwi is not None else 0.34, 0.2, 0.55) + smw = _clamp(smfcf * 0.45, 0.05, max(smfcf - 0.02, 0.06)) + sm0 = _clamp( + min(max(smfcf + 0.08, smw + 0.12), 0.6), + max(smfcf + 0.02, smw + 0.05), + 0.8, + ) + soil_moisture = _sensor_metric(farm, "soil_moisture") + wav = ( + round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3) + if soil_moisture is not None + else DEFAULT_WAV + ) + nitrogen = _pick_first_not_none(_sensor_metric(farm, "nitrogen"), satellite_metrics.get("soil_vv_db")) + phosphorus = _sensor_metric(farm, "phosphorus") + potassium = _sensor_metric(farm, "potassium") + soil_ph = _pick_first_not_none(_sensor_metric(farm, "soil_ph"), None) + ec = _sensor_metric(farm, "electrical_conductivity") + + resolved_soil = { + "SMFCF": round(smfcf, 3), + "SMW": round(smw, 3), + "SM0": round(sm0, 3), + "RDMSOL": 120.0, + "CRAIRC": 0.06, + "SOPE": 10.0, + "KSUB": 10.0, + "soil_moisture": soil_moisture, + "nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI), + "phosphorus": _safe_float(phosphorus, 0.0), + "potassium": _safe_float(potassium, 0.0), + "soil_ph": _safe_float(soil_ph, 7.0), + "electrical_conductivity": _safe_float(ec, 0.0), + "clay": 0.0, + "sand": 0.0, + "silt": 0.0, + "cec": 0.0, + "soc": 0.0, + } + if soil: + resolved_soil.update(soil) + + resolved_site = { + "WAV": wav, + "NAVAILI": _safe_float(nitrogen, DEFAULT_NAVAILI), + "P_STATUS": _safe_float(phosphorus, 0.0), + "K_STATUS": _safe_float(potassium, 0.0), + "SOIL_PH": _safe_float(soil_ph, 7.0), + "EC": _safe_float(ec, 0.0), + "IFUNRN": 0, + "NOTINF": 0.0, + "SSI": 0.0, + "SSMAX": 0.0, + "SMLIM": round(_clamp(_safe_float(_pick_first_not_none(site_parameters and site_parameters.get("SMLIM"), smfcf), smfcf), smw, sm0), 3), + } + if site_parameters: + resolved_site.update(site_parameters) + + simulation_profile = _extract_plant_simulation_profile(plant) + default_crop = ( + deepcopy(simulation_profile.get("crop_parameters")) + if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict) + else _build_default_crop_parameters(plant, plant_name or getattr(plant, "name", "crop")) + ) + resolved_crop = default_crop + if crop_parameters: + resolved_crop.update(crop_parameters) + resolved_crop.setdefault("crop_name", plant_name or getattr(plant, "name", "crop")) + resolved_crop.setdefault("farm_uuid", str(farm_uuid)) + resolved_crop.setdefault("soil_nitrogen", _safe_float(nitrogen, DEFAULT_NAVAILI)) + resolved_crop.setdefault("soil_phosphorus", _safe_float(phosphorus, 0.0)) + resolved_crop.setdefault("soil_potassium", _safe_float(potassium, 0.0)) + # Keep pH in soil/site payloads only; duplicating it in cropdata breaks some PCSE parameter providers. + resolved_crop.pop("soil_ph", None) + + default_agromanagement = ( + deepcopy(simulation_profile.get("agromanagement")) + if simulation_profile and simulation_profile.get("agromanagement") + else _build_default_agromanagement(resolved_crop["crop_name"], resolved_weather) + ) + resolved_agromanagement = agromanagement if agromanagement is not None else default_agromanagement + + return { + "farm": farm, + "runtime_plants": list_runtime_plants_for_farm(farm), + "plant": plant, + "weather": resolved_weather, + "soil": resolved_soil, + "site_parameters": resolved_site, + "crop_parameters": resolved_crop, + "agromanagement": resolved_agromanagement, + } + + +def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float: + total_n = 0.0 + for campaign in agromanagement: + if not isinstance(campaign, dict): + continue + for payload in campaign.values(): + if not isinstance(payload, dict): + continue + for bucket_name in ("TimedEvents", "StateEvents"): + for event_group in payload.get(bucket_name, []) or []: + if not isinstance(event_group, dict): + continue + for event in event_group.get("events_table", []) or []: + if not isinstance(event, dict): + continue + for event_payload in event.values(): + if isinstance(event_payload, dict): + total_n += float(event_payload.get("N_amount", 0.0)) + return total_n + + +def _estimate_pk_stress_factor( + *, + soil: dict[str, Any], + site: dict[str, Any], + crop: dict[str, Any], +) -> dict[str, float]: + phosphorus = _safe_float( + _pick_first_not_none(site.get("P_STATUS"), soil.get("phosphorus"), crop.get("soil_phosphorus")), + 0.0, + ) + potassium = _safe_float( + _pick_first_not_none(site.get("K_STATUS"), soil.get("potassium"), crop.get("soil_potassium")), + 0.0, + ) + soil_ph = _safe_float( + _pick_first_not_none(site.get("SOIL_PH"), soil.get("soil_ph"), crop.get("soil_ph")), + 7.0, + ) + ec = _safe_float(_pick_first_not_none(site.get("EC"), soil.get("electrical_conductivity")), 0.0) + + phosphorus_target = _safe_float(crop.get("P_OPTIMAL"), 30.0) + potassium_target = _safe_float(crop.get("K_OPTIMAL"), 45.0) + p_factor = _clamp(phosphorus / max(phosphorus_target, 1.0), 0.45, 1.0) + k_factor = _clamp(potassium / max(potassium_target, 1.0), 0.45, 1.0) + + ph_penalty = 1.0 + if soil_ph < 5.8: + ph_penalty = _clamp(1.0 - ((5.8 - soil_ph) * 0.08), 0.65, 1.0) + elif soil_ph > 7.8: + ph_penalty = _clamp(1.0 - ((soil_ph - 7.8) * 0.06), 0.7, 1.0) + + ec_penalty = 1.0 + if ec > 2.5: + ec_penalty = _clamp(1.0 - ((ec - 2.5) * 0.07), 0.72, 1.0) + + combined_factor = round(_clamp(p_factor * k_factor * ph_penalty * ec_penalty, 0.35, 1.0), 4) + return { + "phosphorus_factor": round(p_factor, 4), + "potassium_factor": round(k_factor, 4), + "ph_penalty": round(ph_penalty, 4), + "ec_penalty": round(ec_penalty, 4), + "combined_factor": combined_factor, + } + + +def _apply_pk_adjustment( + result: dict[str, Any], + *, + soil: dict[str, Any], + site: dict[str, Any], + crop: dict[str, Any], +) -> dict[str, Any]: + adjustment = _estimate_pk_stress_factor(soil=soil, site=site, crop=crop) + factor = adjustment["combined_factor"] + if factor >= 0.995: + result["nutrient_adjustment"] = adjustment + return result + + metrics = dict(result.get("metrics", {})) + for key, scale in {"yield_estimate": factor, "biomass": factor, "max_lai": max(factor, 0.6)}.items(): + if metrics.get(key) is not None: + metrics[key] = round(_safe_float(metrics[key]) * scale, 4) + result["metrics"] = metrics + result["nutrient_adjustment"] = adjustment + return result + + +def _load_pcse_bindings() -> dict[str, Any] | None: + try: + base_module = importlib.import_module("pcse.base") + models_module = importlib.import_module("pcse.models") + except ImportError: + return None + + parameter_provider = getattr(base_module, "ParameterProvider", None) + weather_provider = getattr(base_module, "WeatherDataProvider", object) + weather_container = getattr(base_module, "WeatherDataContainer", None) + if weather_container is None or parameter_provider is None: + return None + + return { + "ParameterProvider": parameter_provider, + "WeatherDataProvider": weather_provider, + "WeatherDataContainer": weather_container, + "models": models_module, + } + + +def _resolve_model_class(bindings: dict[str, Any], model_name: str): + models_source = bindings["models"] + if isinstance(models_source, dict): + return models_source[model_name] + return getattr(models_source, model_name) + + +@dataclass +class PreparedSimulationInput: + weather: list[dict[str, Any]] + soil: dict[str, Any] + crop: dict[str, Any] + site: dict[str, Any] + agromanagement: list[dict[str, Any]] + + +class PcseSimulationManager: + def __init__(self, model_name: str = DEFAULT_PCSE_MODEL_NAME): + self.model_name = model_name + + def run_simulation( + self, + *, + weather: Any, + soil: dict[str, Any], + crop_parameters: dict[str, Any], + agromanagement: Any, + site_parameters: dict[str, Any] | None = None, + ) -> dict[str, Any]: + prepared = PreparedSimulationInput( + weather=_normalize_weather_records(weather), + soil=soil or {}, + crop=crop_parameters or {}, + site=_normalize_site_parameters_for_model( + self.model_name, + site_parameters or {}, + soil_parameters=soil or {}, + ), + agromanagement=_normalize_agromanagement(agromanagement), + ) + bindings = _load_pcse_bindings() + if bindings is None: + raise CropSimulationError( + "PCSE is not installed or required PCSE classes could not be loaded." + ) + result = self._run_with_pcse(prepared, bindings) + if self.model_name.startswith("Wofost81_NWLP"): + result = _apply_pk_adjustment( + result, + soil=prepared.soil, + site=prepared.site, + crop=prepared.crop, + ) + return result + + def _run_with_pcse( + self, + prepared: PreparedSimulationInput, + bindings: dict[str, Any], + ) -> dict[str, Any]: + weather_provider_base = bindings["WeatherDataProvider"] + weather_container = bindings["WeatherDataContainer"] + parameter_provider_cls = bindings["ParameterProvider"] + model_cls = _resolve_model_class(bindings, self.model_name) + + class DictWeatherProvider(weather_provider_base): + def __init__(self, records: list[dict[str, Any]]): + super().__init__() + self._records = { + item["DAY"]: weather_container(**item) + for item in records + } + + def __call__(self, day): + return self._records[_coerce_date(day)] + + parameter_provider = parameter_provider_cls( + cropdata=prepared.crop, + soildata=prepared.soil, + sitedata=prepared.site, + ) + simulation = model_cls( + parameterprovider=parameter_provider, + weatherdataprovider=DictWeatherProvider(prepared.weather), + agromanagement=prepared.agromanagement, + output_vars=DEFAULT_OUTPUT_VARS, + summary_vars=DEFAULT_SUMMARY_VARS, + terminal_vars=DEFAULT_TERMINAL_VARS, + ) + if hasattr(simulation, "run_till_terminate"): + simulation.run_till_terminate() + elif hasattr(simulation, "run"): + simulation.run(days=len(prepared.weather)) + else: + raise CropSimulationError("PCSE model does not expose a runnable interface.") + + daily_output = _normalize_pcse_output_records(simulation.get_output()) + summary_output = _normalize_pcse_output_records(simulation.get_summary_output()) + terminal_output = _normalize_pcse_output_records(simulation.get_terminal_output()) + return self._build_result( + engine="pcse", + daily_output=daily_output, + summary_output=summary_output, + terminal_output=terminal_output, + ) + + def _build_result( + self, + *, + engine: str, + daily_output: list[dict[str, Any]], + summary_output: list[dict[str, Any]], + terminal_output: list[dict[str, Any]], + ) -> dict[str, Any]: + terminal = terminal_output[-1] if terminal_output else {} + summary = summary_output[-1] if summary_output else {} + final_daily = daily_output[-1] if daily_output else {} + metrics = { + "yield_estimate": _pick_first_not_none( + terminal.get("TWSO"), + summary.get("TWSO"), + final_daily.get("TWSO"), + ), + "biomass": _pick_first_not_none( + terminal.get("TAGP"), + summary.get("TAGP"), + final_daily.get("TAGP"), + ), + "max_lai": _pick_first_not_none( + terminal.get("LAI"), + summary.get("LAIMAX"), + final_daily.get("LAI"), + ), + } + return { + "engine": engine, + "model_name": self.model_name, + "metrics": _json_ready(metrics), + "daily_output": _json_ready(daily_output), + "summary_output": _json_ready(summary_output), + "terminal_output": _json_ready(terminal_output), + } + + +class CropSimulationService: + def __init__(self, manager: PcseSimulationManager | None = None): + self.manager = manager or PcseSimulationManager() + + def _resolve_common_inputs( + self, + *, + farm_uuid: str | None = None, + plant_name: str | None = None, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crop_parameters: dict[str, Any] | None = None, + agromanagement: Any | None = None, + site_parameters: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if not farm_uuid: + return { + "weather": weather, + "soil": soil or {}, + "crop_parameters": crop_parameters or {}, + "agromanagement": agromanagement, + "site_parameters": _normalize_site_parameters_for_model( + self.manager.model_name, + site_parameters or {}, + soil_parameters=soil or {}, + ), + "farm": None, + "plant": None, + } + + base = build_simulation_payload_from_farm( + farm_uuid=str(farm_uuid), + plant_name=plant_name or (crop_parameters or {}).get("crop_name"), + weather=weather, + soil=soil, + crop_parameters=crop_parameters, + agromanagement=agromanagement, + site_parameters=site_parameters, + ) + base["site_parameters"] = _normalize_site_parameters_for_model( + self.manager.model_name, + base.get("site_parameters"), + soil_parameters=base.get("soil"), + ) + return base + + def run_single_simulation( + self, + *, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crop_parameters: dict[str, Any] | None = None, + agromanagement: Any | None = None, + site_parameters: dict[str, Any] | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + name: str = "", + farm_uuid: str | None = None, + plant_name: str | None = None, + ) -> dict[str, Any]: + resolved = self._resolve_common_inputs( + farm_uuid=farm_uuid, + plant_name=plant_name, + weather=weather, + soil=soil, + crop_parameters=crop_parameters, + agromanagement=agromanagement, + site_parameters=site_parameters, + ) + merged_agromanagement = _merge_management_recommendations( + resolved["agromanagement"], + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + scenario = SimulationScenario.objects.create( + name=name, + scenario_type=SimulationScenario.ScenarioType.SINGLE, + model_name=self.manager.model_name, + input_payload=_json_ready( + { + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": resolved["crop_parameters"], + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + "irrigation_recommendation": irrigation_recommendation or {}, + "fertilization_recommendation": fertilization_recommendation or {}, + "farm_uuid": farm_uuid, + "plant_name": plant_name, + } + ), + ) + run = SimulationRun.objects.create( + scenario=scenario, + run_key="single", + label=name or "single", + weather_payload=_json_ready(resolved["weather"]), + soil_payload=_json_ready(resolved["soil"]), + crop_payload=_json_ready(resolved["crop_parameters"]), + site_payload=_json_ready(resolved["site_parameters"]), + agromanagement_payload=_json_ready(merged_agromanagement), + ) + return self._execute_scenario( + scenario=scenario, + run_specs=[ + { + "instance": run, + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": resolved["crop_parameters"], + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + } + ], + ) + + def compare_crops( + self, + *, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crop_a: dict[str, Any], + crop_b: dict[str, Any], + agromanagement: Any | None = None, + site_parameters: dict[str, Any] | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + name: str = "", + farm_uuid: str | None = None, + ) -> dict[str, Any]: + resolved = self._resolve_common_inputs( + farm_uuid=farm_uuid, + weather=weather, + soil=soil, + crop_parameters=None, + agromanagement=agromanagement, + site_parameters=site_parameters, + ) + merged_agromanagement = _merge_management_recommendations( + resolved["agromanagement"], + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + scenario = SimulationScenario.objects.create( + name=name, + scenario_type=SimulationScenario.ScenarioType.CROP_COMPARISON, + model_name=self.manager.model_name, + input_payload=_json_ready( + { + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_a": crop_a, + "crop_b": crop_b, + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + "irrigation_recommendation": irrigation_recommendation or {}, + "fertilization_recommendation": fertilization_recommendation or {}, + "farm_uuid": farm_uuid, + } + ), + ) + runs = [ + SimulationRun.objects.create( + scenario=scenario, + run_key="crop_a", + label=crop_a.get("crop_name", "crop_a"), + weather_payload=_json_ready(resolved["weather"]), + soil_payload=_json_ready(resolved["soil"]), + crop_payload=_json_ready(crop_a), + site_payload=_json_ready(resolved["site_parameters"]), + agromanagement_payload=_json_ready(merged_agromanagement), + ), + SimulationRun.objects.create( + scenario=scenario, + run_key="crop_b", + label=crop_b.get("crop_name", "crop_b"), + weather_payload=_json_ready(resolved["weather"]), + soil_payload=_json_ready(resolved["soil"]), + crop_payload=_json_ready(crop_b), + site_payload=_json_ready(resolved["site_parameters"]), + agromanagement_payload=_json_ready(merged_agromanagement), + ), + ] + return self._execute_scenario( + scenario=scenario, + run_specs=[ + { + "instance": runs[0], + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": crop_a, + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + }, + { + "instance": runs[1], + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": crop_b, + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + }, + ], + ) + + def recommend_best_crop( + self, + *, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crops: list[dict[str, Any]] | None = None, + agromanagement: Any | None = None, + site_parameters: dict[str, Any] | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + name: str = "", + farm_uuid: str | None = None, + ) -> dict[str, Any]: + if not crops and farm_uuid: + base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid)) + crops = [] + for plant in base["runtime_plants"]: + simulation_profile = _extract_plant_simulation_profile(plant) + crop_payload = ( + deepcopy(simulation_profile.get("crop_parameters")) + if simulation_profile and isinstance(simulation_profile.get("crop_parameters"), dict) + else _build_default_crop_parameters(plant, plant.name) + ) + crop_payload.setdefault("crop_name", plant.name) + crop_payload.setdefault("label", plant.name) + crops.append(crop_payload) + + crops = crops or [] + if len(crops) < 2: + raise CropSimulationError("At least two crop options are required.") + + resolved = self._resolve_common_inputs( + farm_uuid=farm_uuid, + weather=weather, + soil=soil, + crop_parameters=None, + agromanagement=agromanagement, + site_parameters=site_parameters, + ) + merged_agromanagement = _merge_management_recommendations( + resolved["agromanagement"], + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + + scenario = SimulationScenario.objects.create( + name=name, + scenario_type=SimulationScenario.ScenarioType.CROP_COMPARISON, + model_name=self.manager.model_name, + input_payload=_json_ready( + { + "weather": resolved["weather"], + "soil": resolved["soil"], + "crops": crops, + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + "irrigation_recommendation": irrigation_recommendation or {}, + "fertilization_recommendation": fertilization_recommendation or {}, + "farm_uuid": farm_uuid, + } + ), + ) + + run_specs = [] + for index, crop in enumerate(crops, start=1): + label = ( + crop.get("label") + or crop.get("crop_name") + or crop.get("name") + or f"crop_{index}" + ) + run = SimulationRun.objects.create( + scenario=scenario, + run_key=f"crop_{index}", + label=label, + weather_payload=_json_ready(resolved["weather"]), + soil_payload=_json_ready(resolved["soil"]), + crop_payload=_json_ready(crop), + site_payload=_json_ready(resolved["site_parameters"]), + agromanagement_payload=_json_ready(merged_agromanagement), + ) + run_specs.append( + { + "instance": run, + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": crop, + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + } + ) + + result = self._execute_scenario(scenario=scenario, run_specs=run_specs) + comparison = result.get("comparison", {}) + return { + "scenario_id": result["scenario_id"], + "scenario_type": result["scenario_type"], + "recommended_crop": { + "run_key": comparison.get("best_run_key"), + "label": comparison.get("best_label"), + "expected_yield_estimate": comparison.get("best_yield_estimate"), + }, + "candidates": comparison.get("runs", []), + "raw_result": result, + } + + def compare_fertilization_strategies( + self, + *, + weather: Any | None = None, + soil: dict[str, Any] | None = None, + crop_parameters: dict[str, Any] | None = None, + strategies: list[dict[str, Any]], + site_parameters: dict[str, Any] | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + name: str = "", + farm_uuid: str | None = None, + plant_name: str | None = None, + ) -> dict[str, Any]: + if len(strategies) < 2: + raise CropSimulationError("At least two fertilization strategies are required.") + + resolved = self._resolve_common_inputs( + farm_uuid=farm_uuid, + plant_name=plant_name, + weather=weather, + soil=soil, + crop_parameters=crop_parameters, + agromanagement=None, + site_parameters=site_parameters, + ) + scenario = SimulationScenario.objects.create( + name=name, + scenario_type=SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON, + model_name=self.manager.model_name, + input_payload=_json_ready( + { + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": resolved["crop_parameters"], + "site_parameters": resolved["site_parameters"], + "strategies": strategies, + "irrigation_recommendation": irrigation_recommendation or {}, + "fertilization_recommendation": fertilization_recommendation or {}, + "farm_uuid": farm_uuid, + "plant_name": plant_name, + } + ), + ) + run_specs = [] + for index, strategy in enumerate(strategies, start=1): + merged_agromanagement = _merge_management_recommendations( + strategy["agromanagement"], + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + run = SimulationRun.objects.create( + scenario=scenario, + run_key=f"strategy_{index}", + label=strategy.get("label", f"strategy_{index}"), + weather_payload=_json_ready(resolved["weather"]), + soil_payload=_json_ready(resolved["soil"]), + crop_payload=_json_ready(resolved["crop_parameters"]), + site_payload=_json_ready(resolved["site_parameters"]), + agromanagement_payload=_json_ready(merged_agromanagement), + ) + run_specs.append( + { + "instance": run, + "weather": resolved["weather"], + "soil": resolved["soil"], + "crop_parameters": resolved["crop_parameters"], + "site_parameters": resolved["site_parameters"], + "agromanagement": merged_agromanagement, + } + ) + return self._execute_scenario(scenario=scenario, run_specs=run_specs) + + def get_scenario_result(self, scenario_id: int) -> dict[str, Any]: + scenario = SimulationScenario.objects.prefetch_related("runs").get(pk=scenario_id) + return { + "id": scenario.id, + "name": scenario.name, + "scenario_type": scenario.scenario_type, + "status": scenario.status, + "model_name": scenario.model_name, + "input_payload": scenario.input_payload, + "result_payload": scenario.result_payload, + "error_message": scenario.error_message, + "runs": [ + { + "id": run.id, + "run_key": run.run_key, + "label": run.label, + "status": run.status, + "result_payload": run.result_payload, + "error_message": run.error_message, + } + for run in scenario.runs.all() + ], + } + + def _execute_scenario( + self, + *, + scenario: SimulationScenario, + run_specs: list[dict[str, Any]], + ) -> dict[str, Any]: + scenario.status = SimulationScenario.Status.RUNNING + scenario.error_message = "" + scenario.save(update_fields=["status", "error_message", "updated_at"]) + + results = [] + try: + for spec in run_specs: + run = spec["instance"] + run.status = SimulationScenario.Status.RUNNING + run.error_message = "" + run.save(update_fields=["status", "error_message", "updated_at"]) + result = self.manager.run_simulation( + weather=spec["weather"], + soil=spec["soil"], + crop_parameters=spec["crop_parameters"], + agromanagement=spec["agromanagement"], + site_parameters=spec["site_parameters"], + ) + run.status = SimulationScenario.Status.SUCCESS + run.result_payload = result + run.save(update_fields=["status", "result_payload", "updated_at"]) + results.append( + { + "run_key": run.run_key, + "label": run.label, + "result": result, + } + ) + except Exception as exc: + message = str(exc) + run = spec["instance"] + run.status = SimulationScenario.Status.FAILURE + run.error_message = message + run.save(update_fields=["status", "error_message", "updated_at"]) + scenario.status = SimulationScenario.Status.FAILURE + scenario.error_message = message + scenario.result_payload = {"runs": results} + scenario.save( + update_fields=["status", "error_message", "result_payload", "updated_at"] + ) + raise + + scenario_result = self._build_scenario_result(scenario, results) + scenario.status = SimulationScenario.Status.SUCCESS + scenario.result_payload = scenario_result + scenario.error_message = "" + scenario.save( + update_fields=["status", "result_payload", "error_message", "updated_at"] + ) + return scenario_result + + def _build_scenario_result( + self, + scenario: SimulationScenario, + results: list[dict[str, Any]], + ) -> dict[str, Any]: + payload = { + "scenario_id": scenario.id, + "scenario_type": scenario.scenario_type, + "status": SimulationScenario.Status.SUCCESS, + "runs": results, + } + if scenario.scenario_type == SimulationScenario.ScenarioType.SINGLE: + payload["result"] = results[0]["result"] + return payload + + run_metrics = [ + { + "run_key": item["run_key"], + "label": item["label"], + "yield_estimate": float(item["result"]["metrics"]["yield_estimate"] or 0.0), + "biomass": float(item["result"]["metrics"]["biomass"] or 0.0), + } + for item in results + ] + best = max(run_metrics, key=lambda item: item["yield_estimate"]) + payload["comparison"] = { + "best_run_key": best["run_key"], + "best_label": best["label"], + "best_yield_estimate": best["yield_estimate"], + "runs": run_metrics, + } + if scenario.scenario_type == SimulationScenario.ScenarioType.CROP_COMPARISON: + if len(run_metrics) >= 2: + payload["comparison"]["yield_gap"] = round( + abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]), + 3, + ) + if ( + scenario.scenario_type + == SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON + ): + payload["recommendation"] = { + "recommended_run_key": best["run_key"], + "recommended_label": best["label"], + "expected_yield_estimate": best["yield_estimate"], + } + return payload + + +@transaction.atomic +def run_single_simulation(**kwargs) -> dict[str, Any]: + return CropSimulationService().run_single_simulation(**kwargs) + + +@transaction.atomic +def compare_crops(**kwargs) -> dict[str, Any]: + return CropSimulationService().compare_crops(**kwargs) + + +@transaction.atomic +def recommend_best_crop(**kwargs) -> dict[str, Any]: + return CropSimulationService().recommend_best_crop(**kwargs) + + +@transaction.atomic +def compare_fertilization_strategies(**kwargs) -> dict[str, Any]: + return CropSimulationService().compare_fertilization_strategies(**kwargs) diff --git a/Modules/Ai/crop_simulation/tasks.py b/Modules/Ai/crop_simulation/tasks.py new file mode 100644 index 0000000..8b82b2f --- /dev/null +++ b/Modules/Ai/crop_simulation/tasks.py @@ -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, + ) diff --git a/Modules/Ai/crop_simulation/test_growth_simulation_api.py b/Modules/Ai/crop_simulation/test_growth_simulation_api.py new file mode 100644 index 0000000..4eccbbb --- /dev/null +++ b/Modules/Ai/crop_simulation/test_growth_simulation_api.py @@ -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) diff --git a/Modules/Ai/crop_simulation/test_single_run.py b/Modules/Ai/crop_simulation/test_single_run.py new file mode 100644 index 0000000..1f20817 --- /dev/null +++ b/Modules/Ai/crop_simulation/test_single_run.py @@ -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"]) diff --git a/Modules/Ai/crop_simulation/test_single_run_with_recommendations.py b/Modules/Ai/crop_simulation/test_single_run_with_recommendations.py new file mode 100644 index 0000000..aabab24 --- /dev/null +++ b/Modules/Ai/crop_simulation/test_single_run_with_recommendations.py @@ -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"]) diff --git a/Modules/Ai/crop_simulation/tests.py b/Modules/Ai/crop_simulation/tests.py new file mode 100644 index 0000000..01a2631 --- /dev/null +++ b/Modules/Ai/crop_simulation/tests.py @@ -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"]) diff --git a/Modules/Ai/crop_simulation/urls.py b/Modules/Ai/crop_simulation/urls.py new file mode 100644 index 0000000..de6f068 --- /dev/null +++ b/Modules/Ai/crop_simulation/urls.py @@ -0,0 +1,24 @@ +from django.urls import path + +from .views import ( + CurrentFarmSimulationChartView, + HarvestPredictionView, + PlantGrowthSimulationStatusView, + PlantGrowthSimulationView, + YieldHarvestSummaryView, + YieldPredictionView, +) + + +urlpatterns = [ + path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"), + path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"), + path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), + path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"), + path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"), + path( + "growth//status/", + PlantGrowthSimulationStatusView.as_view(), + name="growth-simulation-status", + ), +] diff --git a/Modules/Ai/crop_simulation/views.py b/Modules/Ai/crop_simulation/views.py new file mode 100644 index 0000000..6f4ad5e --- /dev/null +++ b/Modules/Ai/crop_simulation/views.py @@ -0,0 +1,571 @@ +from __future__ import annotations + +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.integration_contract import build_integration_meta +from config.openapi import ( + build_envelope_serializer, + build_response, + build_task_status_data_serializer, +) + +from .growth_simulation import MAX_PAGE_SIZE, paginate_growth_stages +from .serializers import ( + CurrentFarmChartRequestSerializer, + CurrentFarmChartResponseSerializer, + GrowthSimulationQueuedSerializer, + GrowthSimulationRequestSerializer, + GrowthSimulationResultSerializer, + HarvestPredictionRequestSerializer, + HarvestPredictionResponseSerializer, + YieldHarvestSummaryQuerySerializer, + YieldHarvestSummaryResponseSerializer, + YieldPredictionRequestSerializer, + YieldPredictionResponseSerializer, +) +from .tasks import run_growth_simulation_task +from .yield_harvest_summary import YieldHarvestSummaryService + + +GrowthSimulationQueuedResponseSerializer = build_envelope_serializer( + "GrowthSimulationQueuedResponseSerializer", + GrowthSimulationQueuedSerializer, +) +GrowthSimulationStatusResponseSerializer = build_envelope_serializer( + "GrowthSimulationStatusResponseSerializer", + build_task_status_data_serializer( + "GrowthSimulationTaskStatusDataSerializer", + GrowthSimulationResultSerializer, + ), +) +GrowthSimulationErrorSerializer = build_envelope_serializer( + "GrowthSimulationErrorSerializer", + data_required=False, + allow_null=True, +) + + +def _get_async_result(task_id: str): + from celery.result import AsyncResult + + return AsyncResult(task_id) + + +def _coerce_positive_int(value, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(parsed, 1) + + +def _fa_task_status(status_name: str) -> str: + return { + "PENDING": "در انتظار", + "PROGRESS": "در حال پردازش", + "SUCCESS": "موفق", + "FAILURE": "ناموفق", + }.get(status_name, status_name) + + +class PlantGrowthSimulationView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="شروع شبیه سازی رشد گیاه", + description=( + "نوع گیاه و پارامترهای متغیر رشد را می گیرد، " + "شبیه سازی را داخل Celery اجرا می کند و فقط task_id برمی گرداند." + ), + request=GrowthSimulationRequestSerializer, + responses={ + 202: build_response( + GrowthSimulationQueuedResponseSerializer, + "تسک شبیه سازی رشد گیاه در صف قرار گرفت.", + ), + 400: build_response( + GrowthSimulationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست با weather مستقیم", + value={ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"], + "weather": [ + { + "DAY": "2026-04-01", + "LAT": 35.7, + "LON": 51.4, + "TMIN": 12, + "TMAX": 24, + "RAIN": 0.0, + "ET0": 0.32, + } + ], + "soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0}, + "site_parameters": {"WAV": 40.0}, + "irrigation_recommendation": {"events": [{"date": "2026-04-02", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-02", "N_amount": 45, "N_recovery": 0.7}] + }, + "page_size": 2, + }, + request_only=True, + ), + OpenApiExample( + "نمونه درخواست با farm", + value={ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP"], + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = GrowthSimulationRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = run_growth_simulation_task.delay(serializer.validated_data) + return Response( + { + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": task.id, + "status_url": f"/api/crop-simulation/growth/{task.id}/status/", + "plant_name": serializer.validated_data["plant_name"], + }, + "meta": build_integration_meta( + flow_type="live_ai_inference", + source_type="provider", + source_service="ai_crop_simulation", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class PlantGrowthSimulationStatusView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="وضعیت شبیه سازی رشد گیاه", + description="وضعیت تسک Celery را برمی گرداند و در صورت موفقیت مراحل رشد را به صورت صفحه بندی شده بازمی گرداند.", + responses={ + 200: build_response( + GrowthSimulationStatusResponseSerializer, + "وضعیت فعلی تسک شبیه سازی رشد گیاه.", + ) + }, + ) + def get(self, request, task_id: str): + result = _get_async_result(task_id) + payload = { + "task_id": task_id, + "status": result.state, + "status_fa": _fa_task_status(result.state), + } + + if result.state == "PENDING": + payload["message"] = "تسک در صف یا یافت نشد." + elif result.state == "PROGRESS": + payload["progress"] = result.info + elif result.state == "SUCCESS": + task_result = dict(result.result or {}) + page = _coerce_positive_int(request.query_params.get("page", 1), 1) + page_size = min( + _coerce_positive_int( + request.query_params.get("page_size", task_result.get("default_page_size", 10)), + 10, + ), + MAX_PAGE_SIZE, + ) + paginated = paginate_growth_stages( + task_result.get("stage_timeline", []), + page=page, + page_size=page_size, + ) + task_result["stages_page"] = paginated["items"] + task_result["pagination"] = paginated["pagination"] + payload["result"] = task_result + elif result.state == "FAILURE": + payload["error"] = str(result.result) + + return Response( + { + "code": 200, + "msg": "موفق", + "data": payload, + "meta": build_integration_meta( + flow_type="live_ai_inference", + source_type="provider", + source_service="ai_crop_simulation", + ownership="ai", + live=result.state in {"PENDING", "PROGRESS", "SUCCESS"}, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + + +CurrentFarmChartEnvelopeSerializer = build_envelope_serializer( + "CurrentFarmChartEnvelopeSerializer", + CurrentFarmChartResponseSerializer, +) +HarvestPredictionEnvelopeSerializer = build_envelope_serializer( + "HarvestPredictionEnvelopeSerializer", + HarvestPredictionResponseSerializer, +) +YieldPredictionEnvelopeSerializer = build_envelope_serializer( + "YieldPredictionEnvelopeSerializer", + YieldPredictionResponseSerializer, +) +YieldHarvestSummaryEnvelopeSerializer = build_envelope_serializer( + "YieldHarvestSummaryEnvelopeSerializer", + YieldHarvestSummaryResponseSerializer, +) + + +class CurrentFarmSimulationChartView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="chart شبیه سازی وضعیت فعلی مزرعه", + description=( + "با دریافت farm_uuid، یک شبیه سازی از وضعیت فعلی مزرعه اجرا می کند و داده chart شامل برگ، وزن، بیوماس، رطوبت و خروجی روزانه را برمی گرداند." + ), + request=CurrentFarmChartRequestSerializer, + responses={ + 200: build_response( + CurrentFarmChartEnvelopeSerializer, + "خروجی chart شبیه سازی وضعیت فعلی مزرعه.", + ), + 400: build_response( + GrowthSimulationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + GrowthSimulationErrorSerializer, + "خطا در اجرای chart شبیه سازی مزرعه.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست chart", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = CurrentFarmChartRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator() + try: + result = simulator.simulate(**serializer.validated_data) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در اجرای chart شبیه سازی مزرعه: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_chart", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class HarvestPredictionView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="پیش بینی زمان تقریبی برداشت", + description=( + "با دریافت farm_uuid، از شبیه ساز رشد برای برآورد زمان باقی مانده تا برداشت استفاده می کند " + "و تاریخ تقریبی برداشت را برمی گرداند." + ), + request=HarvestPredictionRequestSerializer, + responses={ + 200: build_response( + HarvestPredictionEnvelopeSerializer, + "خروجی پیش بینی زمان برداشت مزرعه.", + ), + 400: build_response( + GrowthSimulationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + GrowthSimulationErrorSerializer, + "خطا در پیش بینی زمان برداشت.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست harvest prediction", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = HarvestPredictionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("crop_simulation").get_harvest_prediction_service() + try: + result = service.get_harvest_prediction(**serializer.validated_data) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پیش بینی زمان برداشت: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_harvest_prediction", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class YieldPredictionView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="پیش بینی عملکرد مزرعه", + description="با دریافت farm_uuid، خروجی شبیه ساز رشد را به برآورد عملکرد قابل استفاده در KPI تبدیل می کند.", + request=YieldPredictionRequestSerializer, + responses={ + 200: build_response(YieldPredictionEnvelopeSerializer, "خروجی پیش بینی عملکرد مزرعه."), + 400: build_response(GrowthSimulationErrorSerializer, "داده ورودی نامعتبر است."), + 500: build_response(GrowthSimulationErrorSerializer, "خطا در پیش بینی عملکرد."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست yield prediction", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = YieldPredictionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + service = apps.get_app_config("crop_simulation").get_yield_prediction_service() + try: + result = service.get_yield_prediction(**serializer.validated_data) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response( + { + "code": 200, + "msg": "موفق", + "data": result, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_yield_prediction", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class YieldHarvestSummaryView(APIView): + @extend_schema( + tags=["Crop Simulation"], + summary="خلاصه عملکرد و برداشت", + description=( + "خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. " + "این endpoint خروجی derived واقعی تولید می کند و پاسخ آن mock نیست." + ), + parameters=[ + OpenApiParameter( + name="farm_uuid", + type=str, + location=OpenApiParameter.QUERY, + required=True, + description="شناسه یکتای مزرعه", + ), + OpenApiParameter( + name="season_year", + type=int, + location=OpenApiParameter.QUERY, + required=False, + description="سال زراعی", + ), + OpenApiParameter( + name="crop_name", + type=str, + location=OpenApiParameter.QUERY, + required=False, + description="نام محصول", + ), + OpenApiParameter( + name="include_narrative", + type=bool, + location=OpenApiParameter.QUERY, + required=False, + description="در آینده روایت متنی را نیز اضافه می کند.", + ), + OpenApiParameter( + name="irrigation_recommendation", + type=str, + location=OpenApiParameter.QUERY, + required=False, + description="JSON برنامه آبیاری برای تزریق به شبیه سازی PCSE.", + ), + OpenApiParameter( + name="fertilization_recommendation", + type=str, + location=OpenApiParameter.QUERY, + required=False, + description="JSON برنامه کودهی برای تزریق به شبیه سازی PCSE.", + ), + ], + responses={ + 200: build_response( + YieldHarvestSummaryEnvelopeSerializer, + "خروجی خلاصه عملکرد و برداشت مزرعه.", + ), + 400: build_response( + GrowthSimulationErrorSerializer, + "پارامترهای query نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه پاسخ yield harvest summary", + value={ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": {}, + "yield_prediction": {}, + "harvest_prediction_card": {}, + "harvest_readiness_zones": {}, + "yield_quality_bands": {}, + "harvest_operations_card": {}, + "yield_prediction_chart": {}, + }, + }, + response_only=True, + ), + ], + ) + def get(self, request): + serializer = YieldHarvestSummaryQuerySerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = YieldHarvestSummaryService() + payload = service.get_summary( + farm_uuid=str(validated["farm_uuid"]), + season_year=str(validated.get("season_year") or ""), + crop_name=validated.get("crop_name") or "", + include_narrative=validated.get("include_narrative", False), + irrigation_recommendation=validated.get("irrigation_recommendation"), + fertilization_recommendation=validated.get("fertilization_recommendation"), + ) + return Response( + { + "code": 200, + "msg": "موفق", + "data": payload, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_crop_simulation_yield_harvest_summary", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + diff --git a/Modules/Ai/crop_simulation/water_stress.py b/Modules/Ai/crop_simulation/water_stress.py new file mode 100644 index 0000000..fe9c90b --- /dev/null +++ b/Modules/Ai/crop_simulation/water_stress.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from statistics import mean +from typing import Any + +from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm + +from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +def _level_for_index(water_stress: int) -> str: + if water_stress <= 20: + return "پایین" + if water_stress <= 45: + return "متوسط" + return "بالا" + + +def _stage_sensitivity(dvs: float) -> tuple[str, float]: + if dvs < 0.2: + return "establishment", 0.9 + if dvs < 1.0: + return "vegetative", 1.0 + if dvs < 1.3: + return "flowering", 1.2 + if dvs < 2.0: + return "reproductive", 1.1 + return "maturity", 0.85 + + +def _compute_water_stress_index( + *, + daily_output: list[dict[str, Any]], + soil_parameters: dict[str, Any], +) -> tuple[int, dict[str, Any]]: + latest = daily_output[-1] if daily_output else {} + recent_window = daily_output[-3:] if daily_output else [] + + smfcf = _safe_float(soil_parameters.get("SMFCF"), 0.34) + smw = _safe_float(soil_parameters.get("SMW"), 0.14) + rdmsol = max(_safe_float(soil_parameters.get("RDMSOL"), 120.0), 1.0) + + latest_sm = _safe_float(latest.get("SM"), 0.0) + available_water_ratio = _clamp((latest_sm - smw) / max(smfcf - smw, 0.01), 0.0, 1.0) + moisture_deficit = (1.0 - available_water_ratio) * 65.0 + + recent_et0 = mean(_safe_float(item.get("ET0"), 0.0) for item in recent_window) if recent_window else 0.0 + et0_pressure = _clamp((recent_et0 / 0.45) * 18.0, 0.0, 18.0) + + recent_rain = sum(_safe_float(item.get("RAIN"), 0.0) for item in recent_window) + rainfall_relief = _clamp(recent_rain * 2.5, 0.0, 15.0) + + moisture_trend = 0.0 + if len(recent_window) >= 2: + moisture_trend = max( + (_safe_float(recent_window[0].get("SM"), latest_sm) - latest_sm) * 100.0, + 0.0, + ) + trend_pressure = _clamp(moisture_trend * 1.6, 0.0, 12.0) + + stage_code, stage_multiplier = _stage_sensitivity(_safe_float(latest.get("DVS"), 0.0)) + root_depth_relief = _clamp(((rdmsol - 60.0) / 60.0) * 6.0, 0.0, 6.0) + + raw_score = ((moisture_deficit + et0_pressure + trend_pressure - rainfall_relief - root_depth_relief) * + stage_multiplier) + water_stress = int(round(_clamp(raw_score, 0.0, 100.0))) + + return water_stress, { + "soilMoisturePercent": round(latest_sm * 100.0, 2), + "availableWaterRatio": round(available_water_ratio, 4), + "fieldCapacity": round(smfcf, 4), + "wiltingPoint": round(smw, 4), + "rootDepthCm": round(rdmsol, 2), + "recentEt0": round(recent_et0, 4), + "recentRain": round(recent_rain, 2), + "soilMoistureDrop": round(moisture_trend, 2), + "developmentStage": round(_safe_float(latest.get("DVS"), 0.0), 4), + "stageCode": stage_code, + "stageSensitivity": round(stage_multiplier, 2), + "engine": "crop_simulation", + "formula": ( + "stress = clamp(((moisture_deficit + et0_pressure + trend_pressure - " + "rainfall_relief - root_depth_relief) * stage_sensitivity), 0, 100)" + ), + } + + +class WaterStressSimulationService: + def _resolve_plant_name(self, *, farm_uuid: str, plant_name: str | None) -> str: + if plant_name: + return plant_name + + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + raise GrowthSimulationError("Farm not found.") + + plant = get_runtime_plant_for_farm(farm) + if plant is None: + raise GrowthSimulationError("Plant not found for the selected farm.") + return plant.name + + def get_water_stress( + self, + *, + farm_uuid: str, + plant_name: str | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + resolved_plant_name = self._resolve_plant_name(farm_uuid=farm_uuid, plant_name=plant_name) + context = build_growth_context( + { + "farm_uuid": farm_uuid, + "plant_name": resolved_plant_name, + } + ) + simulation_result, _scenario_id, simulation_warning = _run_simulation( + context, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + daily_output = simulation_result.get("daily_output") or [] + if not daily_output: + raise GrowthSimulationError("Water stress simulation produced no daily output.") + + water_stress, source_metric = _compute_water_stress_index( + daily_output=daily_output, + soil_parameters=context.soil_parameters, + ) + if simulation_warning: + source_metric["simulationWarning"] = simulation_warning + + return { + "farm_uuid": str(farm_uuid), + "plant_name": context.plant_name, + "waterStressIndex": water_stress, + "level": _level_for_index(water_stress), + "sourceMetric": source_metric, + } diff --git a/Modules/Ai/crop_simulation/yield_harvest_summary.py b/Modules/Ai/crop_simulation/yield_harvest_summary.py new file mode 100644 index 0000000..9077275 --- /dev/null +++ b/Modules/Ai/crop_simulation/yield_harvest_summary.py @@ -0,0 +1,1024 @@ +from __future__ import annotations + +import copy +import logging +import math +from datetime import date, datetime +from typing import Any, Callable + +from django.apps import apps +from django.conf import settings + +from farm_data.models import SensorData +from farm_data.services import get_farm_details +from location_data.models import NdviObservation, SoilLocation + +from rag.failure_contract import RAGServiceError +from rag.services.yield_harvest import YieldHarvestRAGService + +logger = logging.getLogger(__name__) + +READINESS_STATUS_FA = { + "ready": "آماده", + "approaching": "نزدیک به آمادگی", + "monitoring": "نیازمند پایش", + "not_ready": "آماده نیست", +} + + +class YieldHarvestSummaryService: + def get_summary( + self, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool = True, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + farm_context = self._get_farm_context(farm_uuid) + farm_context["season_year"] = season_year + farm_context["crop_name"] = crop_name or farm_context.get("crop_name") or "" + farm_context["irrigation_recommendation"] = irrigation_recommendation or {} + farm_context["fertilization_recommendation"] = fertilization_recommendation or {} + yield_prediction = self._build_yield_prediction( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + harvest_prediction_card = self._build_harvest_prediction_card( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + harvest_readiness_zones = self._build_harvest_readiness_zones( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + yield_quality_bands = self._build_yield_quality_bands( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + ) + harvest_operations_card = self._build_harvest_operations_card( + farm_context=farm_context, + harvest_prediction_card=harvest_prediction_card, + pcse_dvs_stage=self._extract_pcse_dvs_stage(harvest_prediction_card), + ) + yield_prediction_chart = self._build_yield_prediction_chart( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + season_highlights_card = self._build_season_highlights_card( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name, + include_narrative=include_narrative, + farm_context=farm_context, + yield_prediction=yield_prediction, + harvest_prediction_card=harvest_prediction_card, + harvest_readiness_zones=harvest_readiness_zones, + yield_quality_bands=yield_quality_bands, + ) + + deterministic_payload = { + "farm_uuid": farm_uuid, + "season_highlights_card": season_highlights_card, + "yield_prediction": yield_prediction, + "harvest_prediction_card": harvest_prediction_card, + "harvest_readiness_zones": harvest_readiness_zones, + "yield_quality_bands": yield_quality_bands, + "harvest_operations_card": harvest_operations_card, + "yield_prediction_chart": yield_prediction_chart, + } + context_payload = { + **copy.deepcopy(deterministic_payload), + "farm_context": farm_context, + } + + if not include_narrative: + return deterministic_payload + + try: + rag_service = YieldHarvestRAGService() + narrative_data = rag_service.generate_narrative(context_payload) + except RAGServiceError as exc: + logger.warning( + "Yield harvest narrative generation failed for farm_uuid=%s: %s", + farm_uuid, + exc, + ) + narrative_data = { + "status": "error", + "source": "llm", + "narrative_error": exc.to_dict(), + } + return self._merge_narrative(deterministic_payload, narrative_data) + + def _build_yield_prediction( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + service = apps.get_app_config("crop_simulation").get_yield_prediction_service() + result = service.get_yield_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + supporting_metrics = dict(result.get("supportingMetrics") or {}) + + # Secondary KPIs are placeholders until dedicated deterministic formulas land. + supporting_metrics.setdefault( + "estimatedKpis", + { + "season_year": season_year, + "applied_rule": "simple_placeholder_rules", + "is_estimated": True, + }, + ) + + return { + "farm_uuid": result.get("farm_uuid", farm_uuid), + "crop_name": result.get("plant_name") or crop_name, + "season_year": season_year, + "predicted_yield_tons": result.get("predictedYieldTons"), + "predicted_yield_raw": result.get("predictedYieldRaw"), + "unit": result.get("unit"), + "source_unit": result.get("sourceUnit"), + "simulation_engine": result.get("simulationEngine"), + "simulation_model": result.get("simulationModel"), + "scenario_id": result.get("scenarioId"), + "simulation_warning": result.get("simulationWarning"), + "secondary_kpis_estimated": True, + "descriptionSource": "deterministic", + "farm_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "soil_data_provider": farm_context.get("soil", {}).get("provider"), + }, + "supporting_metrics": supporting_metrics, + } + + def _build_harvest_prediction_card( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + service = apps.get_app_config("crop_simulation").get_harvest_prediction_service() + result = service.get_harvest_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + + fallback_description = ( + f"پیش بینی قطعی برداشت برای {crop_name or 'محصول انتخاب شده'} " + f"در فصل زراعی {season_year}." + ) + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "harvest_date": result.get("date"), + "harvest_date_formatted": result.get("dateFormatted"), + "days_until": result.get("daysUntil"), + "optimal_window_start": result.get("optimalWindowStart"), + "optimal_window_end": result.get("optimalWindowEnd"), + "description": result.get("description") or fallback_description, + "descriptionSource": "قطعی", + "field_conditions": { + "soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + "soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"), + }, + "readiness_metrics": result.get("gddDetails") or {}, + } + + def _build_yield_prediction_chart( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator() + result = simulator.simulate( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + pcse_timeseries = list(result.get("daily_output") or []) + + yield_series: list[list[float]] = [] + biomass_series: list[list[float]] = [] + for item in pcse_timeseries: + timestamp = self._to_unix_timestamp(item.get("DAY")) + if timestamp is None: + continue + + twso = self._safe_chart_value(item.get("TWSO")) + if twso is not None: + yield_series.append([timestamp, twso]) + + tagp = self._safe_chart_value(item.get("TAGP")) + if tagp is not None: + biomass_series.append([timestamp, tagp]) + + return { + "farm_uuid": farm_uuid, + "crop_name": result.get("plant_name") or crop_name, + "season_year": season_year, + "series": [ + { + "name": "عملکرد پیش بینی شده", + "type": "line", + "data": yield_series, + }, + { + "name": "بیوماس", + "type": "area", + "data": biomass_series, + }, + ], + "xAxis": {"type": "datetime", "label": "تاریخ"}, + "meta": { + "unit": "کیلوگرم در هکتار", + "simulation_engine": result.get("engine"), + "simulation_model": result.get("model_name"), + "scenario_id": result.get("scenario_id"), + "simulation_warning": result.get("simulation_warning"), + "field_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "center_coordinates": farm_context.get("center_coordinates"), + }, + }, + } + + def _build_harvest_operations_card( + self, + *, + farm_context: dict[str, Any], + harvest_prediction_card: dict[str, Any], + pcse_dvs_stage: float, + ) -> dict[str, Any]: + days_until = int(harvest_prediction_card.get("days_until") or 0) + stage_label, phase_name = self._map_dvs_to_phase(pcse_dvs_stage) + steps = self._build_operations_steps( + phase_name=phase_name, + days_until=days_until, + soil_moisture=farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + ) + + return { + "farm_uuid": farm_context.get("farm_uuid"), + "crop_name": farm_context.get("crop_name"), + "season_year": farm_context.get("season_year"), + "stage_label": stage_label, + "phase_name": phase_name, + "days_until_harvest": days_until, + "current_dvs": round(pcse_dvs_stage, 4), + "summary": ( + f"عملیات برداشت برای {farm_context.get('crop_name') or 'محصول انتخاب شده'} " + f"با توجه به {days_until} روز باقی مانده تا بازه پیش بینی شده برداشت اولویت بندی شده است." + ), + "rules_source": "قواعد_قطعی_DVS", + "field_context": { + "soil_type": farm_context.get("soil", {}).get("soil_type"), + "soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"), + "soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"), + }, + "steps": steps, + } + + def _build_season_highlights_card( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + yield_prediction: dict[str, Any], + harvest_prediction_card: dict[str, Any], + harvest_readiness_zones: dict[str, Any], + yield_quality_bands: dict[str, Any], + ) -> dict[str, Any]: + primary_quality_grade = ( + yield_quality_bands.get("primary_quality_grade") + or yield_quality_bands.get("top_band") + or yield_quality_bands.get("summary") + ) + average_readiness = harvest_readiness_zones.get("averageReadiness") + total_predicted_yield = yield_prediction.get("predicted_yield_tons") + target_harvest_date = ( + harvest_prediction_card.get("harvest_date_formatted") + or harvest_prediction_card.get("harvest_date") + ) + estimated_revenue = self._get_estimated_revenue( + farm_uuid=farm_uuid, + total_predicted_yield=total_predicted_yield, + ) + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "title": "خلاصه فصل", + # Left blank for narrative merge unless a non-LLM fallback is needed later. + "subtitle": "", + "total_predicted_yield": total_predicted_yield, + "yield_unit": yield_prediction.get("unit"), + "target_harvest_date": target_harvest_date, + "days_until_harvest": harvest_prediction_card.get("days_until"), + "average_readiness": average_readiness, + "primary_quality_grade": primary_quality_grade, + "estimated_revenue": estimated_revenue, + "soil_type": farm_context.get("soil", {}).get("soil_type"), + } + + def _build_harvest_readiness_zones( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None or sensor.center_location is None: + return { + "farm_uuid": farm_uuid, + "averageReadiness": None, + "zones": [], + "source": "سرویس_سلامت_NDVI", + } + + location = sensor.center_location + ndvi_service = apps.get_app_config("location_data").get_ndvi_health_service() + health_card = ndvi_service.get_ndvi_health(farm_uuid=farm_uuid) + + observations = list( + location.ndvi_observations.order_by("-observation_date", "-created_at")[:2] + ) + latest_observation = observations[0] if observations else None + previous_observation = observations[1] if len(observations) > 1 else None + + latest_ndvi = self._safe_float(health_card.get("mean_ndvi"), None) + previous_ndvi = self._safe_float( + previous_observation.mean_ndvi if previous_observation else None, + None, + ) + ndvi_trend = None + if latest_ndvi is not None and previous_ndvi is not None: + ndvi_trend = round(latest_ndvi - previous_ndvi, 4) + + grid = {} + if latest_observation and isinstance(latest_observation.ndvi_map, dict): + grid = latest_observation.ndvi_map + ndvi_grid = grid.get("grid") if isinstance(grid, dict) else None + + zones: list[dict[str, Any]] = [] + if isinstance(ndvi_grid, list) and ndvi_grid: + zone_index = 1 + for row_index, row in enumerate(ndvi_grid): + if not isinstance(row, list): + continue + for col_index, cell in enumerate(row): + cell_ndvi = self._safe_chart_value(cell) + if cell_ndvi is None: + continue + readiness = self._ndvi_to_readiness(cell_ndvi, ndvi_trend) + zones.append( + { + "zoneId": f"zone-{zone_index}", + "zoneLabel": f"ناحیه {zone_index}", + "gridPosition": {"row": row_index, "col": col_index}, + "meanNdvi": cell_ndvi, + "readiness": readiness, + "daysUntil": self._estimate_days_until_from_readiness(readiness), + "status": self._readiness_status(readiness), + } + ) + zone_index += 1 + + if not zones and latest_ndvi is not None: + readiness = self._ndvi_to_readiness(latest_ndvi, ndvi_trend) + zones.append( + { + "zoneId": "zone-center", + "zoneLabel": "ناحیه مرکزی مزرعه", + "gridPosition": None, + "meanNdvi": latest_ndvi, + "readiness": readiness, + "daysUntil": self._estimate_days_until_from_readiness(readiness), + "status": self._readiness_status(readiness), + } + ) + + average_readiness = None + if zones: + average_readiness = round( + sum(zone["readiness"] for zone in zones) / len(zones), + 2, + ) + + return { + "farm_uuid": farm_uuid, + "observationDate": ( + latest_observation.observation_date.isoformat() + if latest_observation + else health_card.get("observation_date") + ), + "vegetationHealthClass": health_card.get("vegetation_health_class"), + "meanNdvi": latest_ndvi, + "ndviTrend": ndvi_trend, + "averageReadiness": average_readiness, + "zones": zones, + "source": "سرویس_سلامت_NDVI", + } + + def _to_unix_timestamp(self, value: Any) -> int | None: + if isinstance(value, datetime): + return int(value.timestamp() * 1000) + if isinstance(value, date): + return int(datetime.combine(value, datetime.min.time()).timestamp() * 1000) + if isinstance(value, str): + try: + if "T" in value: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + else: + parsed = datetime.combine(date.fromisoformat(value), datetime.min.time()) + return int(parsed.timestamp() * 1000) + except ValueError: + return None + return None + + def _safe_chart_value(self, value: Any) -> float | None: + parsed = self._safe_float(value, None) + if parsed is None or math.isnan(parsed) or math.isinf(parsed): + return None + return round(parsed, 4) + + def _ndvi_to_readiness(self, mean_ndvi: float, trend_delta: float | None) -> int: + base_score = ((0.75 - mean_ndvi) / 0.55) * 100.0 + if trend_delta is not None and trend_delta < 0: + # A falling NDVI near season end suggests drying and harvest readiness. + base_score += min(abs(trend_delta) * 120.0, 18.0) + if trend_delta is not None and trend_delta > 0.05: + base_score -= min(trend_delta * 80.0, 12.0) + return int(round(max(0.0, min(base_score, 100.0)))) + + def _estimate_days_until_from_readiness(self, readiness: int) -> int: + return max(int(round((100 - readiness) / 12.0)), 0) + + def _readiness_status(self, readiness: int) -> str: + if readiness >= 80: + return READINESS_STATUS_FA["ready"] + if readiness >= 55: + return READINESS_STATUS_FA["approaching"] + if readiness >= 30: + return READINESS_STATUS_FA["monitoring"] + return READINESS_STATUS_FA["not_ready"] + + def _build_yield_quality_bands( + self, + *, + farm_uuid: str, + season_year: str, + crop_name: str, + include_narrative: bool, + farm_context: dict[str, Any], + ) -> dict[str, Any]: + crop_key = (crop_name or farm_context.get("crop_name") or "").strip().lower() + yield_service = apps.get_app_config("crop_simulation").get_yield_prediction_service() + yield_payload = yield_service.get_yield_prediction( + farm_uuid=farm_uuid, + plant_name=crop_name or None, + ) + predicted_yield_raw = self._safe_float(yield_payload.get("predictedYieldRaw"), 0.0) or 0.0 + soil_metrics = farm_context.get("soil", {}).get("resolved_metrics") or {} + sensor_metrics = farm_context.get("recent_sensor_averages") or {} + + try: + service = self._resolve_service( + getter_names=( + "get_yield_quality_bands_service", + "get_quality_grading_service", + "get_quality_model_service", + ) + ) + method = self._resolve_service_method( + service, + method_names=( + "get_yield_quality_bands", + "get_quality_bands", + "grade_yield_quality", + ), + ) + return method( + farm_uuid=farm_uuid, + season_year=season_year, + crop_name=crop_name or None, + include_narrative=include_narrative, + ) + except AttributeError: + pass + + protein_content = self._estimate_protein_content( + crop_key=crop_key, + nitrogen_value=self._safe_float(soil_metrics.get("nitrogen"), None), + predicted_yield_raw=predicted_yield_raw, + ) + moisture_percent = self._estimate_moisture_percent( + crop_key=crop_key, + soil_moisture=sensor_metrics.get("soil_moisture"), + ) + quality_score = self._estimate_quality_score( + protein_content=protein_content, + moisture_percent=moisture_percent, + predicted_yield_raw=predicted_yield_raw, + ) + grade_distribution = self._build_grade_distribution(quality_score) + primary_quality_grade = max( + grade_distribution, + key=lambda item: item.get("share_percent", 0), + )["grade"] + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "season_year": season_year, + "source": "قواعد_قطعی_درجه_بندی", + "is_estimated": True, + "protein_content": { + "value": protein_content, + "unit": "%", + }, + "moisture_percentage": { + "value": moisture_percent, + "unit": "%", + }, + "grade_distribution": grade_distribution, + "primary_quality_grade": primary_quality_grade, + "quality_score": quality_score, + "summary": f"درجه کیفیت غالب محصول {primary_quality_grade} است.", + } + + def _get_estimated_revenue( + self, + *, + farm_uuid: str, + total_predicted_yield: float | None, + ) -> float | None: + try: + service = apps.get_app_config("economy").get_economic_overview_service() + overview = service.get_economic_overview(farm_uuid=farm_uuid) + except Exception: + return None + + if not isinstance(overview, dict): + return None + + price_per_ton = None + for item in overview.get("economicData") or []: + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").lower() + value = item.get("value") + if "price" in title or "قیمت" in title: + price_per_ton = self._extract_numeric(value) + break + + if price_per_ton is None or total_predicted_yield is None: + return None + return round(total_predicted_yield * price_per_ton, 2) + + def _estimate_protein_content( + self, + *, + crop_key: str, + nitrogen_value: float | None, + predicted_yield_raw: float, + ) -> float: + nitrogen_factor = 0.0 if nitrogen_value is None else min(nitrogen_value / 2500.0, 2.0) + yield_factor = min(predicted_yield_raw / 10000.0, 1.5) + if "wheat" in crop_key or "گندم" in crop_key: + base = 11.8 + return round(base + (nitrogen_factor * 1.2) - (yield_factor * 0.35), 2) + if "barley" in crop_key or "جو" in crop_key: + base = 10.4 + return round(base + (nitrogen_factor * 0.9) - (yield_factor * 0.25), 2) + return round(9.5 + (nitrogen_factor * 0.8), 2) + + def _estimate_moisture_percent( + self, + *, + crop_key: str, + soil_moisture: float | None, + ) -> float: + soil_component = 0.0 if soil_moisture is None else min(max((soil_moisture - 20.0) / 10.0, -2.0), 4.0) + if "wheat" in crop_key or "barley" in crop_key or "گندم" in crop_key or "جو" in crop_key: + return round(12.6 + soil_component, 2) + return round(11.8 + soil_component, 2) + + def _estimate_quality_score( + self, + *, + protein_content: float, + moisture_percent: float, + predicted_yield_raw: float, + ) -> int: + protein_score = min(max((protein_content / 14.0) * 50.0, 0.0), 50.0) + moisture_penalty = min(abs(moisture_percent - 12.5) * 4.5, 22.0) + yield_bonus = min(predicted_yield_raw / 1500.0, 18.0) + score = protein_score + yield_bonus + 32.0 - moisture_penalty + return int(round(max(0.0, min(score, 100.0)))) + + def _build_grade_distribution(self, quality_score: int) -> list[dict[str, Any]]: + if quality_score >= 85: + return [ + {"grade": "A", "share_percent": 62}, + {"grade": "B", "share_percent": 28}, + {"grade": "C", "share_percent": 10}, + ] + if quality_score >= 70: + return [ + {"grade": "A", "share_percent": 38}, + {"grade": "B", "share_percent": 44}, + {"grade": "C", "share_percent": 18}, + ] + return [ + {"grade": "A", "share_percent": 16}, + {"grade": "B", "share_percent": 41}, + {"grade": "C", "share_percent": 43}, + ] + + def _extract_numeric(self, value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + return None + cleaned = "".join(ch for ch in value if ch.isdigit() or ch in {".", "-"}) + return self._safe_float(cleaned, None) + + def _get_farm_context( + self, + farm_uuid: str, + ) -> dict[str, Any]: + farm = ( + SensorData.objects.select_related("center_location", "weather_forecast") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=farm_uuid) + .first() + ) + if farm is None: + return { + "farm_uuid": farm_uuid, + "center_coordinates": None, + "soil": {"provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص")}, + "recent_sensor_averages": {}, + } + + farm_details = get_farm_details(str(farm_uuid)) or {} + center_location = farm.center_location + soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {} + weather_details = farm_details.get("weather") or {} + recent_sensor_averages = { + "soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None), + "soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None), + "air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), + } + + crop_name = "" + plant_names = farm_details.get("plants") or [] + if plant_names: + first_plant = plant_names[0] + if isinstance(first_plant, dict): + crop_name = str(first_plant.get("name") or "") + + return { + "farm_uuid": farm_uuid, + "crop_name": crop_name, + "center_coordinates": { + "lat": float(center_location.latitude), + "lon": float(center_location.longitude), + }, + "farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"), + "soil": { + "provider": getattr(settings, "SOIL_DATA_PROVIDER", "نامشخص"), + "soil_type": self._infer_soil_type(soil_details), + "resolved_metrics": soil_details, + }, + "recent_sensor_averages": recent_sensor_averages, + "weather": { + "temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None), + "temperature_min": self._safe_float(weather_details.get("temperature_min"), None), + "temperature_max": self._safe_float(weather_details.get("temperature_max"), None), + }, + "source_models": { + "sensor_data": SensorData.__name__, + "soil_location": SoilLocation.__name__, + }, + } + + def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float: + readiness_metrics = harvest_prediction_card.get("readiness_metrics") or {} + forecast = readiness_metrics.get("daily_gdd_forecast") or [{}] + return self._safe_float(forecast[-1].get("development_stage"), 0.0) or 0.0 + + def _map_dvs_to_phase(self, dvs: float) -> tuple[str, str]: + if dvs >= 2.0: + return "آماده", "رسیدگی" + if dvs >= 1.7: + return "پیش_برداشت_نهایی", "زایشی_پایانی" + if dvs >= 1.2: + return "پیش_برداشت_میانی", "پرشدن_دانه" + if dvs >= 0.8: + return "پایش", "گذار_زایشی" + return "پیش_برداشت_ابتدایی", "رشد_رویشی" + + def _build_operations_steps( + self, + *, + phase_name: str, + days_until: int, + soil_moisture: float | None, + ) -> list[dict[str, Any]]: + field_ready = soil_moisture is None or soil_moisture <= 35.0 + + if phase_name == "رسیدگی": + return [ + { + "key": "desiccation", + "title": "بررسی خشک شدن محصول", + "status": "آماده", + "is_completed": False, + "estimated_days": 0, + }, + { + "key": "harvesting", + "title": "برداشت", + "status": "آماده" if field_ready else "نیازمند بررسی شرایط مزرعه", + "is_completed": False, + "estimated_days": max(min(days_until, 2), 0), + }, + { + "key": "transportation", + "title": "انتقال محصول", + "status": "آماده", + "is_completed": False, + "estimated_days": max(min(days_until + 1, 3), 1), + }, + ] + if phase_name == "زایشی_پایانی": + return [ + { + "key": "equipment_check", + "title": "بازبینی تجهیزات برداشت", + "status": "اولویت بالا", + "is_completed": False, + "estimated_days": 1, + }, + { + "key": "labor_plan", + "title": "نهایی کردن برنامه نیروی کار و حمل", + "status": "اولویت بالا", + "is_completed": False, + "estimated_days": 2, + }, + { + "key": "field_entry", + "title": "بررسی امکان ورود به مزرعه و بازه های خشک", + "status": "آماده" if field_ready else "پایش", + "is_completed": False, + "estimated_days": max(min(days_until, 5), 1), + }, + ] + if phase_name == "پرشدن_دانه": + return [ + { + "key": "monitor_maturity", + "title": "پایش رسیدگی و رشد اندام ذخیره ای", + "status": "در حال انجام", + "is_completed": False, + "estimated_days": 7, + }, + { + "key": "review_readiness", + "title": "بررسی اختلاف آمادگی بین ناحیه ها", + "status": "در حال انجام", + "is_completed": False, + "estimated_days": 10, + }, + { + "key": "prepare_logistics", + "title": "آماده سازی برنامه لجستیک برداشت", + "status": "پیش رو", + "is_completed": False, + "estimated_days": 14, + }, + ] + return [ + { + "key": "weekly_monitoring", + "title": "پایش هفتگی رسیدگی محصول", + "status": "در حال انجام", + "is_completed": False, + "estimated_days": 14, + }, + { + "key": "update_forecast", + "title": "به روزرسانی پیش بینی زمان برداشت", + "status": "در حال انجام", + "is_completed": False, + "estimated_days": 10, + }, + { + "key": "draft_operations", + "title": "تهیه چک لیست عملیات برداشت", + "status": "پیش رو", + "is_completed": False, + "estimated_days": 21, + }, + ] + + def _infer_soil_type(self, soil_metrics: dict[str, Any]) -> str | None: + sand = self._safe_float(soil_metrics.get("sand"), None) + clay = self._safe_float(soil_metrics.get("clay"), None) + silt = self._safe_float(soil_metrics.get("silt"), None) + if sand is None or clay is None or silt is None: + return None + if clay >= 40: + return "رسی" + if sand >= 70 and clay <= 15: + return "شنی" + if silt >= 50 and clay < 27: + return "سیلتی لوم" + return "لوم" + + def _safe_float(self, value: Any, default: float | None = 0.0) -> float | None: + try: + if value in (None, ""): + return default + return float(value) + except (TypeError, ValueError): + return default + + def _merge_narrative( + self, + final_payload: dict[str, Any], + narratives: dict[str, Any], + ) -> dict[str, Any]: + merged = copy.deepcopy(final_payload) + if not isinstance(narratives, dict): + narratives = {} + + season_card = merged.setdefault("season_highlights_card", {}) + fallback_subtitle = self._default_season_highlights_subtitle(merged) + season_card["subtitle"] = self._coalesce_text( + narratives.get("season_highlights_subtitle"), + season_card.get("subtitle"), + fallback_subtitle, + ) + + yield_card = merged.setdefault("yield_prediction", {}) + fallback_yield_explanation = self._default_yield_prediction_explanation(merged) + yield_card["explanation"] = self._coalesce_text( + narratives.get("yield_prediction_explanation"), + yield_card.get("explanation"), + fallback_yield_explanation, + ) + + readiness_card = merged.setdefault("harvest_readiness_zones", {}) + fallback_readiness_summary = self._default_harvest_readiness_summary(merged) + readiness_card["summary"] = self._coalesce_text( + narratives.get("harvest_readiness_summary"), + readiness_card.get("summary"), + fallback_readiness_summary, + ) + + operations_card = merged.setdefault("harvest_operations_card", {}) + deterministic_steps = operations_card.get("steps") + operation_notes = narratives.get("operation_notes") + if isinstance(deterministic_steps, list): + note_items = operation_notes if isinstance(operation_notes, list) else [] + for index, step in enumerate(deterministic_steps): + if not isinstance(step, dict): + continue + fallback_note = self._default_operation_note(step) + candidate_note = note_items[index] if index < len(note_items) else None + step["note"] = self._coalesce_text( + candidate_note, + step.get("note"), + fallback_note, + ) + + merged["narrative_status"] = narratives.get("status", "success") + merged["narrative_source"] = narratives.get("source", "deterministic") + if isinstance(narratives.get("narrative_error"), dict): + merged["narrative_error"] = narratives["narrative_error"] + + return merged + + def _coalesce_text(self, *values: Any) -> str: + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def _default_season_highlights_subtitle(self, payload: dict[str, Any]) -> str: + highlights = payload.get("season_highlights_card") or {} + total_yield = highlights.get("total_predicted_yield") + unit = highlights.get("yield_unit") or "" + harvest_date = highlights.get("target_harvest_date") or "بازه پیش بینی شده برداشت" + if total_yield is None: + return f"بر اساس چشم انداز قطعی فصل، برداشت برای {harvest_date} هدف گذاری شده است." + return f"عملکرد پیش بینی شده {total_yield} {unit} است و برداشت برای {harvest_date} هدف گذاری شده است.".strip() + + def _default_yield_prediction_explanation(self, payload: dict[str, Any]) -> str: + yield_card = payload.get("yield_prediction") or {} + predicted = yield_card.get("predicted_yield_tons") + unit = yield_card.get("unit") or "" + if predicted is None: + return "پیش بینی عملکرد بر پایه خروجی قطعی شبیه سازی محصول محاسبه شده است." + return f"پیش بینی عملکرد بر پایه شبیه سازی قطعی محصول انجام شده و در حال حاضر مقدار {predicted} {unit} را نشان می دهد.".strip() + + def _default_harvest_readiness_summary(self, payload: dict[str, Any]) -> str: + readiness = payload.get("harvest_readiness_zones") or {} + average = readiness.get("averageReadiness") + if average is None: + return "آمادگی برداشت از آخرین سیگنال های قطعی ناحیه ای استخراج شده است." + return f"میانگین آمادگی برداشت بر اساس آخرین سیگنال های قطعی ناحیه ای، {average} است.".strip() + + def _default_operation_note(self, step: dict[str, Any]) -> str: + title = step.get("title") or "این عملیات" + status = step.get("status") or "برنامه ریزی شده" + estimate = step.get("estimated_days") + if estimate is None: + return f"وضعیت {title} در حال حاضر «{status}» ثبت شده است." + return f"{title} با وضعیت «{status}» و زمان بندی تقریبی {estimate} روز ثبت شده است.".strip() + + def _resolve_service(self, *, getter_names: tuple[str, ...]) -> Any: + app_config = apps.get_app_config("crop_simulation") + for getter_name in getter_names: + getter = getattr(app_config, getter_name, None) + if callable(getter): + return getter() + raise AttributeError( + f"None of the expected service getters were found on crop_simulation app config: {getter_names}" + ) + + def _resolve_service_method( + self, + service: Any, + *, + method_names: tuple[str, ...], + ) -> Callable[..., dict[str, Any]]: + for method_name in method_names: + method = getattr(service, method_name, None) + if callable(method): + return method + raise AttributeError( + f"None of the expected service methods were found on {service.__class__.__name__}: {method_names}" + ) diff --git a/Modules/Ai/crop_simulation/yield_prediction.py b/Modules/Ai/crop_simulation/yield_prediction.py new file mode 100644 index 0000000..995133d --- /dev/null +++ b/Modules/Ai/crop_simulation/yield_prediction.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import Any + +from .growth_simulation import ( + CurrentFarmChartSimulator, + GrowthSimulationError, + _fa_engine_name, + _fa_model_name, +) + + +def build_yield_prediction_payload( + *, + farm_uuid: str, + plant_name: str | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, +) -> dict[str, Any]: + simulator = CurrentFarmChartSimulator() + result = simulator.simulate( + farm_uuid=farm_uuid, + plant_name=plant_name, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + yield_estimate = float((result.get("metrics") or {}).get("yield_estimate") or 0.0) + predicted_yield_tons = round(max(yield_estimate / 1000.0, 0.0), 2) + return { + "farm_uuid": farm_uuid, + "plant_name": result.get("plant_name"), + "predictedYieldTons": predicted_yield_tons, + "predictedYieldRaw": round(yield_estimate, 2), + "unit": "تن", + "sourceUnit": "کیلوگرم در هکتار", + "simulationEngine": _fa_engine_name(result.get("engine")), + "simulationModel": _fa_model_name(result.get("model_name")), + "scenarioId": result.get("scenario_id"), + "simulationWarning": result.get("simulation_warning"), + "supportingMetrics": result.get("metrics") or {}, + } + + +class YieldPredictionService: + def get_yield_prediction( + self, + *, + farm_uuid: str, + plant_name: str | None = None, + irrigation_recommendation: dict[str, Any] | None = None, + fertilization_recommendation: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + return build_yield_prediction_payload( + farm_uuid=farm_uuid, + plant_name=plant_name, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + except GrowthSimulationError: + raise diff --git a/Modules/Ai/docker-compose-prod.yaml b/Modules/Ai/docker-compose-prod.yaml new file mode 100644 index 0000000..f5ead8c --- /dev/null +++ b/Modules/Ai/docker-compose-prod.yaml @@ -0,0 +1,107 @@ +services: + db: + image: mirror-docker.runflare.com/library/mysql:8 + container_name: ai-db + restart: always + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - ai_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - crop_network + + redis: + image: mirror-docker.runflare.com/library/redis:latest + container_name: ai-redis + restart: always + networks: + - crop_network + + qdrant: + image: mirror-docker.runflare.com/qdrant/qdrant:latest + container_name: ai-qdrant + restart: always + volumes: + - qdrant_data:/qdrant/storage + networks: + - crop_network + + web: + build: + context: . + dockerfile: Dockerfile.Dev + container_name: ai-web + restart: always + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2 + volumes: + - ./logs:/app/logs + - ./static:/app/static + - ./media:/app/media + ports: + - "8020:8000" + env_file: + - .env + environment: + DB_HOST: ai-db + CELERY_BROKER_URL: redis://ai-redis:6379/0 + CELERY_RESULT_BACKEND: redis://ai-redis:6379/0 + QDRANT_HOST: ai-qdrant + QDRANT_PORT: 6333 + DEBUG: "False" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + + networks: + - crop_network + + celery: + build: + context: . + dockerfile: Dockerfile.Dev + container_name: ai-celery + restart: always + command: celery -A config worker -l info --concurrency=4 + healthcheck: + test: ["CMD-SHELL", "celery -A config inspect ping --timeout 10 || exit 1"] + interval: 30s + timeout: 15s + retries: 5 + start_period: 30s + volumes: + - ./logs:/app/logs + - ./media:/app/media + env_file: + - .env + environment: + DB_HOST: ai-db + CELERY_BROKER_URL: redis://ai-redis:6379/0 + CELERY_RESULT_BACKEND: redis://ai-redis:6379/0 + SKIP_MIGRATE: "1" + DEBUG: "False" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: + - crop_network + + +volumes: + ai_mysql_data: + qdrant_data: + +networks: + crop_network: + external: true diff --git a/Modules/Ai/docker-compose.yaml b/Modules/Ai/docker-compose.yaml new file mode 100644 index 0000000..bc81dc7 --- /dev/null +++ b/Modules/Ai/docker-compose.yaml @@ -0,0 +1,123 @@ + +services: + db: + image: docker.iranserver.com/mysql:8 + container_name: ai-mysql + environment: + MYSQL_DATABASE: ${DB_NAME:-ai} + MYSQL_USER: ${DB_USER:-ai} + MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme} + volumes: + - ai_mysql_data:/var/lib/mysql + ports: + - "3307:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - crop_network + + phpmyadmin: + image: docker-mirror.liara.ir/phpmyadmin:latest + container_name: ai-phpmyadmin + environment: + PMA_HOST: ai-mysql + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8083:80" + depends_on: + db: + condition: service_healthy + networks: + - crop_network + + redis: + image: redis:7-alpine + container_name: ai-redis + networks: + - crop_network + + qdrant: + image: qdrant/qdrant:latest + container_name: ai-qdrant + ports: + - "6333:6333" # REST API + - "6334:6334" # gRPC + volumes: + - qdrant_data:/qdrant/storage + restart: unless-stopped + networks: + - crop_network + + web: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror.cdn.ir/repository/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: ai-web + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] + volumes: + - .:/app + - ./logs:/app/logs + ports: + - "8020:8000" + env_file: + - .env + environment: + DB_HOST: ai-mysql + CELERY_BROKER_URL: redis://ai-redis:6379/0 + CELERY_RESULT_BACKEND: redis://ai-redis:6379/0 + QDRANT_HOST: ai-qdrant + QDRANT_PORT: 6333 + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + qdrant: + condition: service_started + networks: + - crop_network + + celery: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror.cdn.ir/repository/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: ai-celery + command: celery -A config worker -l info + volumes: + - .:/app + - ./logs:/app/logs + env_file: + - .env + environment: + DB_HOST: ai-mysql + CELERY_BROKER_URL: redis://ai-redis:6379/0 + CELERY_RESULT_BACKEND: redis://ai-redis:6379/0 + SKIP_MIGRATE: "1" + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: + - crop_network + +volumes: + ai_mysql_data: + qdrant_data: + +networks: + crop_network: + external: true diff --git a/Modules/Ai/docs/crop_simulation_api_reference.md b/Modules/Ai/docs/crop_simulation_api_reference.md new file mode 100644 index 0000000..ddd21bb --- /dev/null +++ b/Modules/Ai/docs/crop_simulation_api_reference.md @@ -0,0 +1,1183 @@ +# Crop Simulation API Reference + +این فایل توضیح کامل API های ماژول `crop_simulation` را پوشش می دهد: + +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth/{task_id}/status/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/crop-simulation/yield-prediction/` + +--- + +## الگوی کلی پاسخ ها + +تقریبا همه endpoint ها از الگوی envelope زیر استفاده می کنند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | integer | کد منطقی پاسخ. معمولا با HTTP status همسو است. | +| `msg` | string | پیام کوتاه پاسخ. در موفقیت معمولا `success` و در خطا متن فارسی خطا است. | +| `data` | object / null | بدنه اصلی داده. در خطاهای validation معمولا شامل جزئیات خطا است. | + +### خطاهای رایج + +| HTTP Status | `code` | توضیح | +|---|---|---| +| `400` | `400` | ورودی نامعتبر است؛ مثل نبودن `farm_uuid` یا ناقص بودن payload | +| `500` | `500` | اجرای سرویس یا شبیه سازی داخلی با خطا مواجه شده است | +| `202` | `202` | تسک async با موفقیت در صف قرار گرفته است | + +--- + +## 1) POST `/api/crop-simulation/current-farm-chart/` + +### کاربرد + +برای ساخت chart وضعیت فعلی مزرعه با استفاده از شبیه سازی رشد. +این endpoint داده هایی مثل: + +- روند روزانه رشد +- بیوماس +- وزن محصول +- شاخص سطح برگ +- رطوبت خاک + +را برمی گرداند. + +### درخواست + +#### Body + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه یکتای مزرعه | +| `plant_name` | string | خیر | نام گیاه. اگر ارسال نشود سیستم سعی می کند گیاه پیش فرض مزرعه را تشخیص دهد | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": 12, + "simulation_warning": null, + "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": [] + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string / null | شناسه مزرعه | +| `plant_name` | string | نام گیاهی که شبیه سازی برای آن انجام شده | +| `engine` | string / null | موتور شبیه سازی استفاده شده | +| `model_name` | string / null | نام مدل شبیه سازی | +| `scenario_id` | integer / null | شناسه سناریو اگر در سیستم ثبت شده باشد | +| `simulation_warning` | string / null | هشدار غیر بحرانی؛ مثلا وقتی fallback استفاده شده | +| `categories` | array[string] | محور زمانی نمودار؛ معمولا تاریخ های روزانه | +| `series` | array[object] | سری های نمودار برای رندر frontend | +| `summary` | array[object] | کارت های خلاصه برای نمایش سریع وضعیت فعلی | +| `current_state` | object | وضعیت آخرین روز شبیه سازی | +| `metrics` | object | شاخص های محاسبه شده نهایی | +| `daily_output` | array[object] | خروجی روزانه خام شبیه سازی | + +### ساختار `series` + +هر عضو `series` معمولا این ساختار را دارد: + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | عنوان سری برای chart | +| `key` | string | کلید فنی سری | +| `data` | array[number] | داده های عددی سری به ترتیب `categories` | + +نمونه key های مهم: + +- `leaf_count_estimate` +- `biomass_weight` +- `storage_organ_weight` +- `lai` +- `soil_moisture_percent` + +### ساختار `summary` + +هر آیتم `summary` یک KPI card است: + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | زیرعنوان | +| `amount` | number | مقدار اصلی کارت | +| `unit` | string | واحد | +| `avatarColor` | string | رنگ پیشنهادی برای UI | +| `avatarIcon` | string | آیکن پیشنهادی برای UI | + +### ساختار `current_state` + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ آخرین رکورد | +| `leaf_count_estimate` | number | تعداد برگ تخمینی | +| `leaf_area_index` | number | شاخص سطح برگ (LAI) | +| `biomass_weight` | number | وزن بیوماس | +| `storage_organ_weight` | number | وزن اندام ذخیره ای / محصول | +| `soil_moisture_percent` | number | رطوبت خاک به درصد | +| `development_stage` | number | مرحله رشد گیاه به صورت DVS | +| `gdd` | number | درجه روز رشد همان روز | + +### ساختار `daily_output` + +این بخش خروجی خام شبیه سازی است و معمولا شامل فیلدهایی مثل این هاست: + +- `DAY` +- `DVS` +- `LAI` +- `TAGP` +- `TWSO` +- `SM` +- `GDD` +- `TMIN` +- `TMAX` +- `RAIN` +- `ET0` + +### خطاها + +#### 400 + +وقتی `farm_uuid` ارسال نشود: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +وقتی شبیه ساز داخلی fail شود: + +```json +{ + "code": 500, + "msg": "خطا در اجرای chart شبیه سازی مزرعه: simulator offline", + "data": null +} +``` + +--- + +## 2) POST `/api/crop-simulation/growth/` + +### کاربرد + +برای شروع شبیه سازی رشد گیاه به صورت async. +این endpoint خود نتیجه نهایی را برنمی گرداند؛ فقط یک `task_id` می دهد تا بعدا از endpoint وضعیت استفاده شود. + +### درخواست + +#### نمونه با weather مستقیم + +```json +{ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"], + "weather": [ + { + "DAY": "2026-04-01", + "LAT": 35.7, + "LON": 51.4, + "TMIN": 12, + "TMAX": 24, + "RAIN": 0.0, + "ET0": 0.32 + } + ], + "soil_parameters": { + "SMFCF": 0.34, + "SMW": 0.14, + "RDMSOL": 120.0 + }, + "site_parameters": { + "WAV": 40.0 + }, + "page_size": 2 +} +``` + +#### نمونه با farm + +```json +{ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP"], + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `plant_name` | string | بله | نام گیاه | +| `dynamic_parameters` | array[string] | بله | پارامترهای رشد که باید در خروجی stageها گزارش شوند | +| `farm_uuid` | string (UUID) | شرطی | اگر weather نفرستید لازم است | +| `weather` | array/object | شرطی | اگر `farm_uuid` نفرستید لازم است | +| `soil_parameters` | object | خیر | پارامترهای خاک | +| `site_parameters` | object | خیر | پارامترهای سایت / مزرعه | +| `crop_parameters` | object | خیر | پارامترهای مدل محصول | +| `agromanagement` | object / array | خیر | مدیریت زراعی | +| `page_size` | integer | خیر | اندازه صفحه stage ها، بین `1` تا `50` | + +### نکته مهم validation + +حداقل یکی از این دو باید وجود داشته باشد: + +- `farm_uuid` +- `weather` + +### پاسخ موفق + +```json +{ + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": "growth-task-1", + "status_url": "/api/crop-simulation/growth/growth-task-1/status/", + "plant_name": "گوجه‌فرنگی" + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `task_id` | string | شناسه تسک Celery | +| `status_url` | string | آدرس endpoint وضعیت برای پیگیری نتیجه | +| `plant_name` | string | نام گیاه مربوط به این شبیه سازی | + +### خطاهای رایج + +#### 400 + +اگر نه `farm_uuid` و نه `weather` ارسال نشده باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "non_field_errors": ["یکی از farm_uuid یا weather باید ارسال شود."] + } +} +``` + +--- + +## 3) GET `/api/crop-simulation/growth/{task_id}/status/` + +### کاربرد + +برای گرفتن وضعیت تسک async شبیه سازی رشد. + +### Query Parameters + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `page` | integer | خیر | شماره صفحه stage ها | +| `page_size` | integer | خیر | تعداد stage ها در هر صفحه، حداکثر `50` | + +### حالت های پاسخ + +این endpoint همیشه HTTP `200` می دهد، ولی فیلد `data.status` تعیین می کند تسک در چه وضعیتی است. + +مقادیر مهم: + +- `PENDING` +- `PROGRESS` +- `SUCCESS` +- `FAILURE` + +--- + +### پاسخ در حالت `PENDING` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} +``` + +#### توضیح + +| فیلد | توضیح | +|---|---| +| `task_id` | شناسه تسک | +| `status` | وضعیت فعلی | +| `message` | پیام کمکی برای کاربر | + +--- + +### پاسخ در حالت `PROGRESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PROGRESS", + "progress": { + "current": 2, + "total": 3, + "message": "simulation finished" + } + } +} +``` + +#### توضیح + +| فیلد | نوع | توضیح | +|---|---|---| +| `progress.current` | integer | مرحله فعلی پردازش | +| `progress.total` | integer | تعداد کل مراحل | +| `progress.message` | string | توضیح مرحله جاری | + +--- + +### پاسخ در حالت `SUCCESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "SUCCESS", + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS"], + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": null, + "summary_metrics": {}, + "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 + } + } + } + ], + "stages_page": [ + { + "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 + } + } + } + ], + "pagination": { + "page": 1, + "page_size": 1, + "total_items": 3, + "total_pages": 3, + "has_next": true, + "has_previous": false + }, + "daily_records_count": 7, + "default_page_size": 1 + } + } +} +``` + +### توضیح کامل `result` + +| فیلد | نوع | توضیح | +|---|---|---| +| `plant_name` | string | نام گیاه | +| `dynamic_parameters` | array[string] | پارامترهایی که stage metrics برایشان تولید شده | +| `engine` | string / null | موتور شبیه سازی | +| `model_name` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار غیر بحرانی | +| `summary_metrics` | object | شاخص های خلاصه | +| `stage_timeline` | array[object] | کل timeline مراحل رشد | +| `stages_page` | array[object] | فقط صفحه فعلی timeline | +| `pagination` | object | اطلاعات صفحه بندی | +| `daily_records_count` | integer | تعداد رکوردهای روزانه شبیه سازی | +| `default_page_size` | integer | اندازه صفحه پیش فرض | + +### ساختار هر stage + +| فیلد | نوع | توضیح | +|---|---|---| +| `order` | integer | ترتیب مرحله در timeline | +| `stage_code` | string | کد فنی مرحله رشد | +| `stage_name` | string | نام قابل نمایش مرحله | +| `start_date` | string | تاریخ شروع مرحله | +| `end_date` | string | تاریخ پایان مرحله | +| `days_count` | integer | تعداد روزهای این مرحله | +| `metrics` | object | خلاصه آماری پارامترهای درخواستی | + +### ساختار `metrics` در هر stage + +برای هر پارامتر مثل `DVS` یا `LAI`: + +| فیلد | توضیح | +|---|---| +| `start` | مقدار ابتدای مرحله | +| `end` | مقدار پایان مرحله | +| `min` | کمترین مقدار در مرحله | +| `max` | بیشترین مقدار در مرحله | +| `avg` | میانگین مقدار در مرحله | + +### ساختار `pagination` + +| فیلد | نوع | توضیح | +|---|---|---| +| `page` | integer | شماره صفحه فعلی | +| `page_size` | integer | اندازه صفحه فعلی | +| `total_items` | integer | تعداد کل stage ها | +| `total_pages` | integer | تعداد کل صفحه ها | +| `has_next` | boolean | آیا صفحه بعدی وجود دارد | +| `has_previous` | boolean | آیا صفحه قبلی وجود دارد | + +--- + +### پاسخ در حالت `FAILURE` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "FAILURE", + "error": "task crashed" + } +} +``` + +#### توضیح + +| فیلد | توضیح | +|---|---| +| `error` | متن خطای نهایی تسک | + +--- + +## 4) POST `/api/crop-simulation/harvest-prediction/` + +### کاربرد + +برای پیش بینی زمان تقریبی برداشت بر اساس خروجی شبیه سازی رشد و GDD. + +### درخواست + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه مزرعه | +| `plant_name` | string | خیر | نام گیاه | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "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" + } + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ تخمینی برداشت با فرمت ISO | +| `dateFormatted` | string | همان تاریخ به فرمت human-readable | +| `daysUntil` | integer | تعداد روز باقی مانده تا برداشت | +| `description` | string | توضیح متنی قابل نمایش برای کاربر | +| `optimalWindowStart` | string | شروع بازه مناسب برداشت | +| `optimalWindowEnd` | string | پایان بازه مناسب برداشت | +| `gddDetails` | object | جزئیات محاسبات GDD و داده های پشتیبان | + +### ساختار `gddDetails` + +معمولا شامل این فیلدهاست: + +| فیلد | نوع | توضیح | +|---|---|---| +| `current_cumulative_gdd` | number | GDD تجمعی فعلی | +| `required_gdd_for_maturity` | number | GDD مورد نیاز برای رسیدن به بلوغ | +| `remaining_gdd` | number | میزان GDD باقی مانده | +| `estimated_days_to_harvest` | integer | روزهای برآوردی تا برداشت | +| `predicted_harvest_date` | string | تاریخ برآوردی برداشت | +| `predicted_harvest_window` | object | بازه شروع/پایان مناسب برداشت | +| `daily_gdd_forecast` | array[object] | پیش بینی روزانه GDD | +| `simulation_engine` | string | موتور شبیه سازی | +| `simulation_model_name` | string | نام مدل شبیه سازی | +| `simulation_warning` | string / null | هشدار داخلی شبیه سازی | +| `scenario_id` | integer / null | شناسه سناریو | + +### ساختار `daily_gdd_forecast` + +هر آیتم: + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ | +| `gdd` | number | GDD همان روز | +| `cumulative_gdd` | number | GDD تجمعی تا آن روز | +| `development_stage` | number | DVS آن روز | + +### خطاها + +#### 400 + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +```json +{ + "code": 500, + "msg": "خطا در پیش بینی زمان برداشت: harvest offline", + "data": null +} +``` + +--- + +## 5) GET `/api/crop-simulation/yield-harvest-summary/` + +### کاربرد + +برای برگرداندن داشبورد کامل خلاصه عملکرد و برداشت. +این endpoint ترکیبی از چند block مختلف است: + +- season highlights +- yield prediction +- harvest prediction +- readiness zones +- quality bands +- harvest operations +- yield chart + +این endpoint می تواند علاوه بر داده های deterministic، متن های narrative را هم اضافه کند. + +### Query Parameters + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه یکتای مزرعه | +| `season_year` | integer | خیر | سال زراعی | +| `crop_name` | string | خیر | نام محصول | +| `include_narrative` | boolean | خیر | اگر `true` باشد متن های توضیحی RAG هم merge می شوند | + +### نمونه درخواست + +```http +GET /api/crop-simulation/yield-harvest-summary/?farm_uuid=11111111-1111-1111-1111-111111111111&season_year=1404&crop_name=wheat&include_narrative=true +``` + +### پاسخ موفق + +نمونه واقعی پاسخ فعلی: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "title": "Season highlights", + "subtitle": "Projected harvest in 152 days with minimal yield prediction issues due to simulation fallback.", + "total_predicted_yield": 0.0, + "yield_unit": "تن", + "target_harvest_date": "28 September 2026", + "days_until_harvest": 152, + "average_readiness": null, + "primary_quality_grade": "C", + "estimated_revenue": null, + "soil_type": "loam" + }, + "yield_prediction": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "predicted_yield_tons": 0.0, + "predicted_yield_raw": 0.0, + "unit": "تن", + "source_unit": "kg/ha", + "simulation_engine": "growth_projection", + "simulation_model": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": "Simulation engine failed, fallback projection used: Value for parameter CVL missing.", + "secondary_kpis_estimated": true, + "descriptionSource": "deterministic", + "farm_context": { + "soil_type": "loam", + "soil_data_provider": "mock" + }, + "supporting_metrics": { + "yield_estimate": 0.0, + "biomass": 232.9052, + "max_lai": 0.1063 + }, + "explanation": "شبيه ساز با محدوديت داده مواجه شد؛ براورد فعلی بدون تضمین و با روش جایگزین انجام شده است." + }, + "harvest_prediction_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "harvest_date": "2026-09-28", + "harvest_date_formatted": "28 September 2026", + "days_until": 152, + "optimal_window_start": "2026-09-25", + "optimal_window_end": "2026-10-01", + "description": "شبيه ساز تا انتهاي forecast هنوز به رسيدگي کامل نرسيده، اما با ميانگين رشد فعلي براورد مي شود خیار حدود 152 روز ديگر به برداشت برسد.", + "descriptionSource": "deterministic", + "field_conditions": { + "soil_moisture": 42.3, + "soil_temperature": 21.4 + }, + "readiness_metrics": { + "current_cumulative_gdd": 0.0, + "required_gdd_for_maturity": 1200.0 + } + }, + "harvest_readiness_zones": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "observationDate": null, + "vegetationHealthClass": "Unavailable", + "meanNdvi": null, + "ndviTrend": null, + "averageReadiness": null, + "zones": [], + "source": "ndvi_health_service", + "summary": "خیار هنوز در مراحل اولیه رشد است، 152 روز تا برداشته شدن باقی مانده است. میانگین آمادگی موجود نیست." + }, + "yield_quality_bands": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "", + "season_year": "", + "source": "deterministic_grading_rules", + "is_estimated": true, + "protein_content": { + "value": 9.51, + "unit": "%" + }, + "moisture_percentage": { + "value": 14.03, + "unit": "%" + }, + "grade_distribution": [ + {"grade": "A", "share_percent": 16}, + {"grade": "B", "share_percent": 41}, + {"grade": "C", "share_percent": 43} + ], + "primary_quality_grade": "C", + "quality_score": 59, + "summary": "Primary quality grade is C." + }, + "harvest_operations_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "stage_label": "early_pre_harvest", + "phase_name": "vegetative", + "days_until_harvest": 152, + "current_dvs": 0.0394, + "summary": "Operations are prioritized for خیار with 152 days remaining until the predicted harvest window.", + "rules_source": "deterministic_dvs_rules", + "field_context": { + "soil_type": "loam", + "soil_moisture": 42.3, + "soil_temperature": 21.4 + }, + "steps": [ + { + "key": "weekly_monitoring", + "title": "Run weekly crop maturity checks", + "status": "active", + "is_completed": false, + "estimated_days": 14, + "note": "Check weekly crop status for any signs of maturity changes." + } + ] + }, + "yield_prediction_chart": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "خیار", + "season_year": "", + "series": [ + { + "name": "Predicted Yield", + "type": "line", + "data": [[1777420800000, 0.0]] + }, + { + "name": "Biomass", + "type": "area", + "data": [[1777420800000, 89.392]] + } + ], + "xAxis": { + "type": "datetime" + }, + "meta": { + "unit": "kg/ha", + "simulation_engine": "growth_projection", + "simulation_model": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": "Simulation engine failed, fallback projection used: Value for parameter CVL missing.", + "field_context": { + "soil_type": "loam", + "center_coordinates": { + "lat": 50.0, + "lon": 50.0 + } + } + } + } + } +} +``` + +### توضیح top-level response + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `season_highlights_card` | object | خلاصه مهم ترین KPI ها | +| `yield_prediction` | object | خروجی پیش بینی عملکرد | +| `harvest_prediction_card` | object | تاریخ و وضعیت برداشت | +| `harvest_readiness_zones` | object | وضعیت آمادگی برداشت در zoneها | +| `yield_quality_bands` | object | کیفیت برآوردی محصول | +| `harvest_operations_card` | object | عملیات پیشنهادی برداشت | +| `yield_prediction_chart` | object | داده نمودار عملکرد و بیوماس | + +### توضیح `season_highlights_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | متن توضیحی کوتاه؛ ممکن است از RAG بیاید | +| `total_predicted_yield` | number / null | عملکرد پیش بینی شده | +| `yield_unit` | string | واحد عملکرد | +| `target_harvest_date` | string / null | تاریخ هدف برداشت | +| `days_until_harvest` | integer / null | روز باقی مانده | +| `average_readiness` | number / null | میانگین آمادگی zoneها | +| `primary_quality_grade` | string / null | درجه کیفیت غالب | +| `estimated_revenue` | number / null | درآمد تخمینی اگر داده اقتصادی موجود باشد | +| `soil_type` | string / null | نوع خاک | + +### توضیح `yield_prediction` + +| فیلد | نوع | توضیح | +|---|---|---| +| `predicted_yield_tons` | number | عملکرد بر حسب تن | +| `predicted_yield_raw` | number | عملکرد خام معمولا بر حسب `kg/ha` | +| `unit` | string | واحد نمایشی | +| `source_unit` | string | واحد منبع | +| `simulation_engine` | string / null | موتور شبیه سازی | +| `simulation_model` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه سازی | +| `secondary_kpis_estimated` | boolean | آیا KPIهای ثانویه تخمینی هستند | +| `descriptionSource` | string | منبع توضیح؛ معمولا `deterministic` یا `rag` | +| `farm_context` | object | بخشی از context مزرعه | +| `supporting_metrics` | object | متریک های پشتیبان مثل `yield_estimate` و `biomass` | +| `explanation` | string | توضیح متنی برای کاربر | + +### توضیح `harvest_prediction_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `harvest_date` | string | تاریخ ISO برداشت | +| `harvest_date_formatted` | string | تاریخ قابل نمایش | +| `days_until` | integer | تعداد روز باقی مانده | +| `optimal_window_start` | string | شروع پنجره مناسب برداشت | +| `optimal_window_end` | string | پایان پنجره مناسب برداشت | +| `description` | string | توضیح متنی | +| `descriptionSource` | string | منبع متن | +| `field_conditions` | object | شرایط فعلی مزرعه مثل رطوبت و دما | +| `readiness_metrics` | object | جزئیات محاسبات readiness/GDD | + +### توضیح `harvest_readiness_zones` + +| فیلد | نوع | توضیح | +|---|---|---| +| `observationDate` | string / null | تاریخ مشاهده NDVI | +| `vegetationHealthClass` | string / null | کلاس سلامت پوشش گیاهی | +| `meanNdvi` | number / null | NDVI میانگین | +| `ndviTrend` | number / null | روند تغییر NDVI | +| `averageReadiness` | number / null | میانگین آمادگی zoneها | +| `zones` | array[object] | فهرست zoneها | +| `source` | string | منبع داده | +| `summary` | string | توضیح متنی خلاصه | + +### ساختار هر zone + +| فیلد | نوع | توضیح | +|---|---|---| +| `zoneId` | string | شناسه zone | +| `zoneLabel` | string | نام نمایشی zone | +| `gridPosition` | object / null | موقعیت zone در grid | +| `meanNdvi` | number | میانگین NDVI zone | +| `readiness` | integer | درصد آمادگی برداشت | +| `daysUntil` | integer | روزهای تخمینی تا آمادگی | +| `status` | string | وضعیت مثل `ready`, `approaching`, `monitoring`, `not_ready` | + +### توضیح `yield_quality_bands` + +| فیلد | نوع | توضیح | +|---|---|---| +| `source` | string | منبع محاسبه کیفیت | +| `is_estimated` | boolean | آیا مقادیر تخمینی هستند | +| `protein_content` | object | درصد پروتئین | +| `moisture_percentage` | object | درصد رطوبت | +| `grade_distribution` | array[object] | توزیع درصدی gradeها | +| `primary_quality_grade` | string | grade غالب | +| `quality_score` | number | امتیاز کیفیت | +| `summary` | string | خلاصه متنی کیفیت | + +### ساختار `protein_content` و `moisture_percentage` + +| فیلد | نوع | توضیح | +|---|---|---| +| `value` | number | مقدار | +| `unit` | string | واحد، معمولا `%` | + +### ساختار `grade_distribution` + +| فیلد | نوع | توضیح | +|---|---|---| +| `grade` | string | گرید کیفیت مثل `A`, `B`, `C` | +| `share_percent` | integer | سهم درصدی آن گرید | + +### توضیح `harvest_operations_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `stage_label` | string | برچسب مرحله عملیاتی | +| `phase_name` | string | نام فاز رشد | +| `days_until_harvest` | integer | روز باقی مانده تا برداشت | +| `current_dvs` | number | DVS فعلی | +| `summary` | string | خلاصه عملیاتی | +| `rules_source` | string | منبع قواعد تصمیم | +| `field_context` | object | context مزرعه | +| `steps` | array[object] | گام های عملیاتی | + +### ساختار هر step + +| فیلد | نوع | توضیح | +|---|---|---| +| `key` | string | کلید فنی step | +| `title` | string | عنوان عملیات | +| `status` | string | وضعیت مثل `active`, `upcoming`, `ready` | +| `is_completed` | boolean | آیا انجام شده | +| `estimated_days` | integer | روز برآوردی برای انجام / رسیدن | +| `note` | string | توضیح تکمیلی یا توصیه | + +### توضیح `yield_prediction_chart` + +| فیلد | نوع | توضیح | +|---|---|---| +| `series` | array[object] | سری های نمودار عملکرد و بیوماس | +| `xAxis` | object | تنظیم محور افقی | +| `meta` | object | متادیتای chart | + +### ساختار `yield_prediction_chart.series` + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | نام سری | +| `type` | string | نوع رسم مثل `line` یا `area` | +| `data` | array[[timestamp, value]] | داده های نمودار بر پایه timestamp یونیکس میلی ثانیه | + +### توضیح `yield_prediction_chart.meta` + +| فیلد | نوع | توضیح | +|---|---|---| +| `unit` | string | واحد داده chart | +| `simulation_engine` | string | موتور شبیه سازی | +| `simulation_model` | string | مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه سازی | +| `field_context` | object | context مزرعه مثل نوع خاک و مختصات | + +### خطاها + +#### 400 + +وقتی `farm_uuid` ارسال نشده باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +--- + +## 6) POST `/api/crop-simulation/yield-prediction/` + +### کاربرد + +برای تبدیل خروجی شبیه سازی رشد به پیش بینی عملکرد مزرعه. + +### درخواست + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | string (UUID) | بله | شناسه مزرعه | +| `plant_name` | string | خیر | نام گیاه | + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "plant_name": "گوجه‌فرنگی", + "predictedYieldTons": 5.4, + "predictedYieldRaw": 5400.0, + "unit": "تن", + "sourceUnit": "kg/ha", + "simulationEngine": "growth_projection", + "simulationModel": "growth_projection_v1", + "scenarioId": 12, + "simulationWarning": null, + "supportingMetrics": { + "yield_estimate": 5400.0 + } + } +} +``` + +### توضیح کامل فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `plant_name` | string / null | نام گیاه | +| `predictedYieldTons` | number | عملکرد پیش بینی شده بر حسب تن | +| `predictedYieldRaw` | number | مقدار خام عملکرد | +| `unit` | string | واحد نمایشی، معمولا `تن` | +| `sourceUnit` | string | واحد محاسباتی اصلی، معمولا `kg/ha` | +| `simulationEngine` | string / null | موتور شبیه سازی | +| `simulationModel` | string / null | مدل شبیه سازی | +| `scenarioId` | integer / null | شناسه سناریو | +| `simulationWarning` | string / null | هشدار شبیه سازی | +| `supportingMetrics` | object | متریک های پشتیبان مورد استفاده برای محاسبه عملکرد | + +### خطاها + +#### 400 + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +#### 500 + +```json +{ + "code": 500, + "msg": "خطا در پیش بینی عملکرد: yield offline", + "data": null +} +``` + +--- + +## جمع بندی تفاوت endpoint ها + +| Endpoint | کاربرد اصلی | sync/async | +|---|---|---| +| `POST /current-farm-chart/` | ساخت نمودار وضعیت فعلی مزرعه | sync | +| `POST /growth/` | شروع شبیه سازی رشد | async | +| `GET /growth/{task_id}/status/` | بررسی وضعیت و نتیجه شبیه سازی رشد | async status | +| `POST /harvest-prediction/` | پیش بینی زمان برداشت | sync | +| `GET /yield-harvest-summary/` | داشبورد کامل عملکرد و برداشت | sync | +| `POST /yield-prediction/` | پیش بینی عملکرد نهایی | sync | + +--- + +## نکات مهم برای frontend + +- در endpoint `growth/status` همیشه به `data.status` نگاه کنید، نه فقط HTTP status. +- در پاسخ های chart و summary ممکن است `simulation_warning` مقدار داشته باشد، حتی اگر HTTP `200` باشد. +- در `yield-harvest-summary` بعضی فیلدها ممکن است `null` باشند، مخصوصا: + - `estimated_revenue` + - `average_readiness` + - `scenario_id` + - `simulation_warning` +- در `yield_prediction_chart.series[].data` timestamp ها بر حسب **milliseconds** هستند. +- در `yield-harvest-summary` اگر `include_narrative=true` باشد، بعضی متن ها ممکن است از RAG بیایند ولی اعداد deterministic باقی می مانند. + +--- + +## مسیر فایل + +این داکیومنت در مسیر زیر ذخیره شده است: + +`docs/crop_simulation_api_reference.md` diff --git a/Modules/Ai/docs/farm_alerts_tracker_api.md b/Modules/Ai/docs/farm_alerts_tracker_api.md new file mode 100644 index 0000000..9d0a68e --- /dev/null +++ b/Modules/Ai/docs/farm_alerts_tracker_api.md @@ -0,0 +1,180 @@ +# راهنمای استفاده از API هشدارهای مزرعه + +این سند نحوه کار با API فعال هشدارهای مزرعه را توضیح می‌دهد. + +## Endpoint فعال + +- `POST /api/farm-alerts/tracker/` + +نکته: +- endpoint `POST /api/farm-alerts/timeline/` حذف شده و دیگر قابل استفاده نیست. + +## کاربرد API + +این API با دریافت `farm_uuid` و یک لیست از `alerts`: + +- وضعیت فعلی هشدارهای مزرعه را تحلیل می‌کند +- context مزرعه را همراه با alertهای ارسالی به RAG می‌فرستد +- فقط notificationهای مهم را تولید می‌کند +- notificationهای تولیدشده را در دیتابیس ذخیره می‌کند + +## ساختار درخواست + +فیلدهای ورودی: + +- `farm_uuid`: شناسه مزرعه +- `alerts`: لیست alertهای ورودی برای تحلیل + +فیلد `farm_uuid` الزامی است. + +## ساختار هر alert + +هر آیتم داخل `alerts` می‌تواند این فیلدها را داشته باشد: + +- `alert_id`: شناسه هشدار +- `level`: سطح هشدار مثل `info` یا `warning` یا `danger` +- `title`: عنوان هشدار +- `message`: توضیح هشدار +- `suggested_action`: اقدام پیشنهادی +- `source_metric_type`: نوع شاخص مثل `moisture` +- `timestamp`: زمان هشدار با فرمت datetime +- `payload`: داده تکمیلی به صورت JSON object + +همه فیلدهای داخل هر alert اختیاری هستند، ولی بهتر است برای تحلیل دقیق‌تر حداقل `title` یا `message` و در صورت امکان `level` و `source_metric_type` ارسال شوند. + +## نمونه درخواست + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + "timestamp": "2025-02-14T09:30:00Z", + "payload": { + "window": "3d", + "current_value": 38.5, + "threshold": 45 + } + }, + { + "alert_id": "fungal-risk-002", + "level": "danger", + "title": "ریسک قارچی بالا", + "message": "شرایط محیطی برای بیماری قارچی شدید شده است.", + "suggested_action": "بازدید و اقدام پیشگیرانه فوری انجام شود.", + "source_metric_type": "fungal_risk", + "timestamp": "2025-02-14T10:00:00Z", + "payload": { + "humidity": 89, + "duration_hours": 18 + } + } + ] +} +``` + +## نمونه درخواست با curl + +```bash +curl -X POST http://localhost:8000/api/farm-alerts/tracker/ \ + -H "Content-Type: application/json" \ + -d '{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture" + } + ] + }' +``` + +## ساختار پاسخ موفق + +پاسخ HTTP با envelope زیر برمی‌گردد: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "service_id": "farm_alerts", + "tracker": {}, + "headline": "جمع بندی کوتاه وضعیت هشدارها", + "overview": "توضیح کوتاه و اجرایی از مهم ترین وضعیت مزرعه", + "status_level": "warning", + "notifications": [ + { + "id": 12, + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "endpoint": "tracker", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "تنش رطوبتی در مزرعه ادامه دارد.", + "suggested_action": "آبیاری جبرانی کوتاه مدت اجرا شود.", + "source_alert_id": "soil-moisture-001", + "source_metric_type": "moisture", + "payload": {}, + "created_at": "2025-02-14T10:15:00+00:00", + "updated_at": "2025-02-14T10:15:00+00:00" + } + ], + "raw_llm_response": "{\"headline\":\"...\"}", + "structured_context": {} + } +} +``` + +## وضعیت‌های خطا + +### خطای ورودی نامعتبر + +اگر `farm_uuid` ارسال نشود: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "farm_uuid الزامی است." + ] + } +} +``` + +### خطای داخلی + +اگر در مرحله تحلیل RAG یا تولید پاسخ خطایی رخ دهد: + +```json +{ + "code": 500, + "msg": "خطا در تولید tracker هشدارها: ...", + "data": null +} +``` + +## نکات رفتاری API + +- اگر `alerts` ارسال نشود، API آن را به صورت آرایه خالی در نظر می‌گیرد. +- notificationهای ساخته‌شده برای endpoint `tracker` در دیتابیس ذخیره می‌شوند. +- مدل باید notification تکراری نسازد و اگر مورد مهمی وجود نداشته باشد، خروجی notification می‌تواند خالی باشد. +- تحلیل فقط روی endpoint `tracker` فعال است. + +## پیشنهاد برای مصرف‌کننده API + +- برای هر alert یک `alert_id` پایدار بفرستید تا ردیابی و جلوگیری از تکرار بهتر انجام شود. +- برای alertهای حساس، `timestamp` و `source_metric_type` را حتما ارسال کنید. +- اگر داده تکمیلی دارید، آن را داخل `payload` بفرستید تا RAG context کامل‌تر شود. diff --git a/Modules/Ai/docs/farm_alerts_tracker_response_fields.md b/Modules/Ai/docs/farm_alerts_tracker_response_fields.md new file mode 100644 index 0000000..19bad34 --- /dev/null +++ b/Modules/Ai/docs/farm_alerts_tracker_response_fields.md @@ -0,0 +1,492 @@ +# توضیح فیلدهای پاسخ API هشدارهای مزرعه + +این سند فیلدهای JSON خروجی `POST /api/farm-alerts/tracker/` را توضیح می‌دهد. + +## ساختار کلی پاسخ + +پاسخ API به شکل envelope برمی‌گردد: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +## فیلدهای سطح اول + +### `code` + +- نوع: `number` +- معنی: کد وضعیت داخلی API +- مقدار رایج: + - `200`: موفق + - `400`: ورودی نامعتبر + - `500`: خطای داخلی + +### `msg` + +- نوع: `string` +- معنی: پیام خلاصه وضعیت پاسخ +- نمونه: + - `success` + - `داده نامعتبر.` + - `خطا در تولید tracker هشدارها: ...` + +### `data` + +- نوع: `object | null` +- معنی: بدنه اصلی پاسخ +- در موفقیت شامل جزئیات تحلیل مزرعه است +- در بعضی خطاها ممکن است `null` باشد + +## فیلدهای داخل `data` + +### `farm_uuid` + +- نوع: `string` +- معنی: شناسه مزرعه‌ای که تحلیل روی آن انجام شده + +### `service_id` + +- نوع: `string` +- معنی: شناسه سرویس تولیدکننده پاسخ +- مقدار فعلی: `farm_alerts` + +### `tracker` + +- نوع: `object` +- معنی: خروجی ساختاریافته‌ی موتور tracker قبل از خلاصه‌سازی نهایی AI +- شامل لیست alertها، آمار، خوشه‌بندی و مهم‌ترین مسئله است + +### `headline` + +- نوع: `string` +- معنی: تیتر کوتاه و سریع برای وضعیت فعلی مزرعه +- برای نمایش در کارت، header یا لیست اعلان مناسب است + +### `overview` + +- نوع: `string` +- معنی: جمع‌بندی کوتاه و اجرایی از مهم‌ترین وضعیت فعلی +- معمولا نسخه‌ی خلاصه‌شده‌ی مهم‌ترین alert یا نتیجه کلی تحلیل است + +### `status_level` + +- نوع: `string` +- مقادیر مجاز: + - `info` + - `warning` + - `danger` +- معنی: سطح کلی وضعیت مزرعه از دید AI + +### `notifications` + +- نوع: `array` +- معنی: notificationهای نهایی که پس از تحلیل AI ساخته و در دیتابیس ذخیره شده‌اند +- اگر مورد مهمی وجود نداشته باشد، می‌تواند خالی باشد + +### `raw_llm_response` + +- نوع: `string | null` +- معنی: پاسخ خام JSON که مدل زبانی تولید کرده است +- بیشتر برای debug، audit یا بررسی رفتار AI مفید است + +### `structured_context` + +- نوع: `object` +- معنی: کانتکست ساختاریافته‌ای که به مدل داده شده است +- شامل اطلاعات مزرعه، tracker، forecastها و alertهای ورودی است + +## فیلدهای `tracker` + +### `tracker.totalAlerts` + +- نوع: `number` +- معنی: تعداد کل alertهای شناسایی‌شده توسط tracker + +### `tracker.alerts` + +- نوع: `array` +- معنی: لیست خام alertهای تشخیص‌داده‌شده + +هر آیتم در `tracker.alerts`: + +#### `metric_type` + +- نوع: `string` +- معنی: نوع شاخصی که alert از آن آمده +- نمونه: + - `moisture` + - `temperature` + - `ph` + - `ec` + - `fungal_risk` + +#### `title` + +- نوع: `string` +- معنی: عنوان انسانی alert + +#### `current_value` + +- نوع: `number` +- معنی: مقدار فعلی شاخص + +#### `threshold_value` + +- نوع: `number | string` +- معنی: آستانه مرجع برای تشخیص alert + +#### `severity` + +- نوع: `string` +- مقادیر رایج: + - `low` + - `medium` + - `high` + - `critical` +- معنی: شدت alert در لایه tracker + +#### `duration_hours` + +- نوع: `number` +- معنی: مدت تداوم وضعیت هشدار به ساعت + +#### `duration` + +- نوع: `string` +- معنی: نسخه خوانای `duration_hours` +- نمونه: `3 ساعت` + +#### `timestamp` + +- نوع: `string` +- معنی: زمان مرجع alert با فرمت ISO datetime + +#### `sensor_id` + +- نوع: `string` +- معنی: شناسه مزرعه/سنسوری که alert برای آن محاسبه شده + +#### `zone_id` + +- نوع: `string | null` +- معنی: شناسه ناحیه، اگر alert مربوط به ناحیه خاصی باشد + +#### `domain` + +- نوع: `string` +- معنی: دامنه عملیاتی alert +- نمونه: + - `water_balance` + - `temperature_stress` + - `root_chemistry` + - `disease_pressure` + +#### `direction` + +- نوع: `string` +- معنی: جهت عبور از آستانه +- نمونه: + - `below`: مقدار از حد مجاز کمتر شده + - `above`: مقدار از حد مجاز بیشتر شده + +#### `unit` + +- نوع: `string` +- معنی: واحد شاخص +- نمونه: + - `%` + - `°C` + - `pH` + - `dS/m` + +#### `icon` + +- نوع: `string` +- معنی: نام آیکن پیشنهادی برای UI + +#### `summary` + +- نوع: `string` +- معنی: خلاصه انسانی و کوتاه alert + +#### `recommended_action` + +- نوع: `string` +- معنی: اقدام عملیاتی پیشنهادی tracker + +#### `explanation` + +- نوع: `string` +- معنی: توضیح قابل‌فهم درباره چرایی ایجاد alert + +#### `metadata` + +- نوع: `object` +- معنی: داده تکمیلی برای توسعه‌های بعدی + +## فیلدهای `tracker.alertStats` + +- نوع: `array` +- معنی: خلاصه آماری alertها برای نمایش سریع در UI + +هر آیتم: + +### `title` + +- عنوان دسته alert + +### `count` + +- نوع: `string` +- تعداد alertها در آن دسته + +### `avatarColor` + +- نوع: `string` +- رنگ پیشنهادی UI + +### `avatarIcon` + +- نوع: `string` +- آیکن پیشنهادی UI + +### `severity` + +- نوع: `string` +- بالاترین شدت در آن دسته + +### `topSummary` + +- نوع: `string` +- مهم‌ترین خلاصه در آن دسته + +## فیلدهای `tracker.alertClusters` + +- نوع: `array` +- معنی: گروه‌بندی alertها بر اساس domain + +هر آیتم: + +### `domain` + +- نوع: `string` +- نام دامنه خوشه + +### `title` + +- نوع: `string` +- عنوان انسانی خوشه + +### `alert_count` + +- نوع: `number` +- تعداد alertهای این خوشه + +### `highest_severity` + +- نوع: `string` +- بیشترین شدت بین alertهای این خوشه + +### `primary_metric` + +- نوع: `string` +- مهم‌ترین metric در آن خوشه + +### `summary` + +- نوع: `string` +- خلاصه وضعیت خوشه + +### `alert_ids` + +- نوع: `array` +- معنی: شناسه‌های alertهای عضو خوشه + +## فیلد `tracker.mostCriticalIssue` + +- نوع: `object | null` +- معنی: مهم‌ترین مسئله‌ای که tracker پیدا کرده +- ساختار آن مشابه هر آیتم در `tracker.alerts` است + +## فیلدهای خلاصه‌ای `tracker` + +### `tracker.prioritizedAlertSummaries` + +- نوع: `array` +- معنی: لیستی از summaryهای مهم به ترتیب اولویت + +### `tracker.recommendedOperationalActions` + +- نوع: `array` +- معنی: لیستی از اقدام‌های عملیاتی پیشنهادی + +### `tracker.humanReadableExplanations` + +- نوع: `array` +- معنی: توضیح‌های متنی قابل‌خواندن برای نمایش یا گزارش + +## فیلدهای `notifications` + +- نوع: `array` +- معنی: اعلان‌های نهایی ذخیره‌شده در سیستم + +هر آیتم در `notifications`: + +### `id` + +- نوع: `number` +- معنی: شناسه داخلی notification + +### `uuid` + +- نوع: `string` +- معنی: شناسه یکتای notification + +### `farm_uuid` + +- نوع: `string` +- معنی: شناسه مزرعه مربوط به notification + +### `since_id` + +- نوع: `number | null` +- معنی: شناسه مرجع برای زنجیره یا گروه‌بندی notificationها در سیستم + +### `endpoint` + +- نوع: `string` +- معنی: endpoint تولیدکننده notification +- مقدار فعلی: `tracker` + +### `title` + +- نوع: `string` +- معنی: عنوان notification + +### `message` + +- نوع: `string` +- معنی: متن اصلی notification + +### `level` + +- نوع: `string` +- مقادیر مجاز: + - `info` + - `warning` + - `danger` +- معنی: سطح notification + +### `suggested_action` + +- نوع: `string` +- معنی: اقدام پیشنهادی نهایی برای کاربر + +### `source_alert_id` + +- نوع: `string` +- معنی: شناسه alert مبنا که notification از آن ساخته شده + +### `source_metric_type` + +- نوع: `string` +- معنی: نوع metric مبنا + +### `payload` + +- نوع: `object` +- معنی: داده تکمیلی notification + +### `is_read` + +- نوع: `boolean` +- معنی: آیا notification توسط کاربر خوانده شده یا نه + +### `metadata` + +- نوع: `object` +- معنی: اطلاعات جانبی درباره منبع یا نحوه تولید notification +- نمونه: + - `source: farm_alerts_tracker_ai` + +### `created_at` + +- نوع: `string` +- معنی: زمان ایجاد notification + +### `updated_at` + +- نوع: `string` +- معنی: زمان آخرین به‌روزرسانی notification + +## فیلدهای `structured_context` + +این بخش برای debug و audit مفید است و نشان می‌دهد چه داده‌ای به مدل داده شده است. + +### `structured_context.farm_profile` + +- اطلاعات پایه مزرعه + +فیلدهای مهم: +- `farm_uuid`: شناسه مزرعه +- `location.latitude`: عرض جغرافیایی +- `location.longitude`: طول جغرافیایی +- `plant_names`: لیست گیاه‌های ثبت‌شده +- `irrigation_method`: روش آبیاری یا `null` +- `last_sensor_update`: زمان آخرین داده سنسور + +### `structured_context.tracker` + +- همان داده tracker که به AI داده شده است + +### `structured_context.forecasts` + +- نوع: `array` +- معنی: پیش‌بینی‌های هواشناسی کوتاه‌مدت + +هر آیتم: +- `date`: تاریخ پیش‌بینی +- `temperature_min`: کمینه دما +- `temperature_max`: بیشینه دما +- `humidity_mean`: میانگین رطوبت +- `precipitation`: بارش +- `et0`: تبخیر-تعرق مرجع + +### `structured_context.incoming_alerts` + +- نوع: `array` +- معنی: alertهایی که از request به API ارسال شده و در تحلیل استفاده شده‌اند +- اگر چیزی ارسال نشده باشد، آرایه خالی است + +## تفاوت `severity` و `level` + +این دو فیلد شبیه هم هستند ولی یکسان نیستند: + +- `severity`: شدت داخلی alert در tracker با مقادیر `low/medium/high/critical` +- `level`: سطح نهایی notification یا status کلی با مقادیر `info/warning/danger` + +به طور معمول: + +- `low` معمولا به `info` نزدیک است +- `medium` معمولا به `warning` نزدیک است +- `high` و `critical` معمولا به `danger` نزدیک هستند + +## نکته عملی + +اگر می‌خواهید در frontend فقط پیام نهایی را نمایش دهید، معمولا این فیلدها کافی هستند: + +- `data.headline` +- `data.overview` +- `data.status_level` +- `data.notifications` + +اگر می‌خواهید صفحه تحلیلی یا داشبورد کامل بسازید، از این بخش‌ها هم استفاده کنید: + +- `data.tracker.alerts` +- `data.tracker.alertStats` +- `data.tracker.alertClusters` +- `data.structured_context.forecasts` diff --git a/Modules/Ai/docs/irrigation_fertilization_plan_parser_apis.md b/Modules/Ai/docs/irrigation_fertilization_plan_parser_apis.md new file mode 100644 index 0000000..ab9ed49 --- /dev/null +++ b/Modules/Ai/docs/irrigation_fertilization_plan_parser_apis.md @@ -0,0 +1,619 @@ +# Free-Text Plan Parser APIs + +این فایل برای تیم فرانت‌اند آماده شده و دو API جدید زیر را توضیح می‌دهد: + +- `POST /api/irrigation/plan-from-text/` +- `POST /api/fertilization/plan-from-text/` + +هدف هر دو API: + +- کاربر یک متن آزاد می‌نویسد +- بک‌اند تلاش می‌کند برنامه آبیاری یا کودهی را به JSON ساختاریافته تبدیل کند +- اگر اطلاعات کامل باشد، JSON نهایی برمی‌گردد +- اگر اطلاعات ناقص باشد، API سوال‌های تکمیلی برمی‌گرداند +- فرانت‌اند سوال‌ها را از کاربر می‌پرسد و پاسخ‌ها را دوباره برای API می‌فرستد + +--- + +## رفتار کلی هر دو API + +هر دو endpoint یک flow یکسان دارند: + +1. کاربر متن آزاد اولیه را می‌فرستد +2. اگر متن کامل باشد: + - `status = "completed"` + - `final_plan` برمی‌گردد +3. اگر متن ناقص باشد: + - `status = "needs_clarification"` + - `missing_fields` برمی‌گردد + - `questions` برمی‌گردد +4. فرانت‌اند پاسخ کاربر به سوال‌ها را جمع می‌کند +5. دوباره همان endpoint را با `answers` و `partial_plan` صدا می‌زند +6. این روند تا ساخته شدن `final_plan` ادامه پیدا می‌کند + +--- + +## الگوی کلی response + +هر دو API از envelope استاندارد استفاده می‌کنند: + +```json +{ + "code": 200, + "msg": "موفق", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | number | کد منطقی پاسخ | +| `msg` | string | پیام کوتاه پاسخ | +| `data` | object | داده اصلی API | + +--- + +## 1) API استخراج برنامه آبیاری + +### Endpoint + +```http +POST /api/irrigation/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه آبیاری را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +هر سه فیلد زیر اختیاری هستند، اما حداقل یکی از این‌ها باید ارسال شود: + +- `message` +- `answers` +- `partial_plan` + +#### ساختار request + +```json +{ + "message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.", + "answers": { + "growth_stage": "گلدهی" + }, + "partial_plan": { + "crop_name": "گوجه فرنگی", + "irrigation_method": "قطره ای" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر به سوال‌هایی که قبلا API داده | +| `partial_plan` | object | خیر | خروجی مرحله قبل برای ادامه تکمیل | +| `farm_uuid` | string | خیر | برای غنی‌سازی context مزرعه در RAG | + +### قانون validation + +اگر هیچ‌کدام از `message`، `answers` یا `partial_plan` ارسال نشوند: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +وقتی همه اطلاعات لازم موجود باشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه آبیاری برای گوجه‌فرنگی به روش قطره‌ای هر سه روز یک‌بار صبح زود به مدت 25 دقیقه اجرا می‌شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + }, + "final_plan": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + } + } +} +``` + +### فیلدهای `data` + +| فیلد | نوع | توضیح | +|---|---|---| +| `status` | string | یکی از `completed` یا `needs_clarification` | +| `status_fa` | string | نسخه فارسی وضعیت | +| `summary` | string | خلاصه قابل نمایش برای کاربر | +| `missing_fields` | array[string] | فیلدهای ناقص | +| `questions` | array[object] | سوال‌های تکمیلی | +| `collected_data` | object | داده‌ای که تا الان از متن و جواب‌ها استخراج شده | +| `final_plan` | object/null | برنامه نهایی؛ فقط در حالت `completed` | + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد محصول | +| `irrigation_method` | string | روش آبیاری | +| `water_amount_per_event` | string | مقدار آب هر نوبت | +| `duration_minutes` | number | مدت هر نوبت آبیاری به دقیقه | +| `frequency_text` | string | توصیف متنی فاصله آبیاری | +| `interval_days` | number | فاصله آبیاری بر حسب روز | +| `preferred_time_of_day` | string | زمان مناسب اجرای آبیاری | +| `start_date` | string | زمان یا تاریخ شروع برنامه | +| `target_area` | string | محدوده هدف برنامه | +| `trigger_conditions` | array[string] | شرایط تریگر اختیاری | +| `notes` | array[string] | نکات تکمیلی | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +اگر اطلاعات کامل نباشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "start_date", + "target_area" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای کامل شدن برنامه لازم است." + }, + { + "id": "start_date", + "field": "start_date", + "question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟", + "rationale": "زمان شروع برنامه هنوز مشخص نشده است." + }, + { + "id": "target_area", + "field": "target_area", + "question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟", + "rationale": "محدوده اجرای برنامه باید مشخص باشد." + } + ], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": null, + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "final_plan": null + } +} +``` + +### ساختار `questions` + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | string | شناسه سوال | +| `field` | string | فیلدی که این سوال برای آن پرسیده شده | +| `question` | string | متن سوال برای نمایش به کاربر | +| `rationale` | string | توضیح کوتاه برای اینکه چرا این سوال لازم است | + +--- + +## flow پیشنهادی فرانت‌اند برای آبیاری + +### مرحله 1 + +کاربر متن آزاد می‌فرستد: + +```json +{ + "message": "برای گوجه فرنگی هر سه روز یک بار آبیاری می کنم." +} +``` + +### مرحله 2 + +اگر `status = needs_clarification` بود: + +- سوال‌ها را از `data.questions` به کاربر نمایش بده +- پاسخ‌ها را جمع کن + +### مرحله 3 + +درخواست تکمیلی بزن: + +```json +{ + "partial_plan": { + "crop_name": "گوجه فرنگی", + "growth_stage": null, + "irrigation_method": null, + "water_amount_per_event": null, + "duration_minutes": null, + "frequency_text": "هر سه روز یک بار", + "interval_days": 3, + "preferred_time_of_day": null, + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "answers": { + "growth_stage": "گلدهی", + "irrigation_method": "قطره ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه" + } +} +``` + +### مرحله 4 + +اگر `status = completed` شد: + +- از `data.final_plan` به عنوان JSON نهایی استفاده کن + +--- + +## 2) API استخراج برنامه کودهی + +### Endpoint + +```http +POST /api/fertilization/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه کودهی را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +```json +{ + "message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.", + "answers": { + "timing": "هر 12 روز یک بار" + }, + "partial_plan": { + "crop_name": "گندم" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر | +| `partial_plan` | object | خیر | داده استخراج شده مرحله قبل | +| `farm_uuid` | string | خیر | برای context مزرعه | + +### validation error + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه کودهی برای گندم در مرحله پنجه زنی با کود 20-20-20 به صورت کودآبیاری هر 12 روز یک بار اجرا می شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + }, + "final_plan": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + } + } +} +``` + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد | +| `objective` | string/null | هدف برنامه | +| `applications` | array[object] | لیست نوبت‌ها یا اقلام کودی | +| `notes` | array[string] | نکات تکمیلی | + +### ساختار هر application + +| فیلد | نوع | توضیح | +|---|---|---| +| `fertilizer_name` | string | نام کود | +| `formula` | string | فرمول یا آنالیز کود | +| `amount` | string | مقدار مصرف | +| `application_method` | string | روش مصرف | +| `timing` | string | زمان‌بندی مصرف | +| `interval_days` | number | فاصله بین نوبت‌ها | +| `purpose` | string/null | هدف آن نوبت | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "formula", + "interval_days" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای تکمیل برنامه لازم است." + }, + { + "id": "formula", + "field": "formula", + "question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.", + "rationale": "ترکیب دقیق کود هنوز مشخص نشده است." + }, + { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین نوبت های مصرف کود چند روز است؟", + "rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است." + } + ], + "collected_data": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر چند وقت یک بار", + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "final_plan": null + } +} +``` + +--- + +## flow پیشنهادی فرانت‌اند برای کودهی + +### درخواست اولیه + +```json +{ + "message": "برای گندم از کود کامل استفاده می کنم." +} +``` + +### اگر incomplete بود + +- از `questions` سوال‌ها را بگیر +- در UI نمایش بده +- پاسخ‌ها را جمع کن + +### درخواست تکمیلی + +```json +{ + "partial_plan": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": null, + "application_method": null, + "timing": null, + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "answers": { + "growth_stage": "پنجه زنی", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12 + } +} +``` + +### اگر complete شد + +- از `final_plan` استفاده کن + +--- + +## نکات مهم برای فرانت‌اند + +### 1. به `status` تکیه کنید + +مهم‌ترین فیلد برای کنترل flow: + +- `completed` +- `needs_clarification` + +### 2. اگر `needs_clarification` بود + +باید: + +- `questions` را به کاربر نمایش دهید +- `partial_plan` را نگه دارید +- پاسخ‌های کاربر را در `answers` ارسال کنید + +### 3. اگر `completed` بود + +باید: + +- `final_plan` را به عنوان نسخه نهایی برنامه ذخیره یا نمایش دهید + +### 4. `collected_data` همیشه مهم است + +حتی اگر برنامه ناقص باشد، `collected_data` نشان می‌دهد سیستم تا این لحظه چه چیزهایی را فهمیده است. + +### 5. null در حالت ناقص طبیعی است + +در حالت `needs_clarification` ممکن است بعضی فیلدهای `collected_data` `null` باشند. +اما در حالت `completed` نباید فیلدهای اصلی ناقص باشند. + +### 6. فرانت‌اند بهتر است سوال‌ها را به صورت step-by-step بپرسد + +پیشنهاد: + +- سوال اول را نشان بده +- جواب را بگیر +- همه جواب‌ها را در `answers` جمع کن +- دوباره API را صدا بزن + +--- + +## جمع‌بندی تفاوت دو API + +| API | موضوع | خروجی نهایی | +|---|---|---| +| `/api/irrigation/plan-from-text/` | استخراج برنامه آبیاری | `final_plan` با ساختار آبیاری | +| `/api/fertilization/plan-from-text/` | استخراج برنامه کودهی | `final_plan` با ساختار کودهی | + +--- + +## مسیر فایل + +این داکیومنت در این مسیر ذخیره شده: + +`docs/irrigation_fertilization_plan_parser_apis.md` diff --git a/Modules/Ai/docs/location_and_farm_data_apps_explained.md b/Modules/Ai/docs/location_and_farm_data_apps_explained.md new file mode 100644 index 0000000..383a932 --- /dev/null +++ b/Modules/Ai/docs/location_and_farm_data_apps_explained.md @@ -0,0 +1,512 @@ +# توضیح `location_data/apps.py` و `farm_data/apps.py` + +این فایل یک توضیح کوتاه ولی کاربردی از دو فایل تنظیمات اپ Django در پروژه می‌دهد: + +- `location_data/apps.py` +- `farm_data/apps.py` + +همچنین برای فهم بهتر، به فیلدهای مهم مدل‌های مرتبط هم اشاره می‌کند تا معلوم شود این دو app در عمل چه داده‌هایی را مدیریت می‌کنند. + +--- + +## 1) فایل `location_data/apps.py` + +این فایل AppConfig مربوط به اپ `location_data` را تعریف می‌کند. + +کلاس اصلی: + +```python +class SoilDataConfig(AppConfig): +``` + +### فیلدها و بخش‌ها + +#### `default_auto_field = "django.db.models.BigAutoField"` + +- مشخص می‌کند اگر در مدل‌های این اپ برای primary key چیزی تعریف نشده باشد، Django به‌صورت پیش‌فرض از `BigAutoField` استفاده کند. +- `BigAutoField` یک شناسه عددی auto-increment بزرگ است. +- این گزینه بیشتر برای مدل‌هایی مفید است که قرار است رکوردهای زیادی داشته باشند. + +#### `name = "location_data"` + +- نام کامل اپ Django است. +- Django با این مقدار اپ را register می‌کند. +- این مقدار باید با مسیر ماژول اپ یکی باشد. + +#### `verbose_name = "Soil Data (SoilGrids)"` + +- نام نمایشی اپ در Django admin یا جاهایی است که Django نام انسانی اپ را نشان می‌دهد. +- این مقدار بیشتر جنبه نمایشی دارد و روی منطق برنامه اثر مستقیم ندارد. + +--- + +## propertyها و سرویس‌ها در `location_data/apps.py` + +این فایل فقط metadata اپ را نگه نمی‌دارد؛ دو سرویس reusable هم از طریق AppConfig در اختیار بقیه پروژه می‌گذارد. + +### `@cached_property def ndvi_health_service(self)` + +- این property یک نمونه از `NdviHealthService` می‌سازد. +- import آن از فایل `.ndvi` انجام می‌شود. +- به دلیل `cached_property` فقط یک بار ساخته می‌شود و بعد همان instance دوباره استفاده می‌شود. + +کاربرد: + +- برای تحلیل یا سرویس‌های مرتبط با NDVI +- جلوگیری از ساخت مکرر object + +### `@cached_property def soil_data_adapter(self)` + +این property adapter مناسب برای داده خاک را بر اساس تنظیمات پروژه انتخاب می‌کند. + +دو adapter پشتیبانی می‌شوند: + +- `SoilGridsAdapter` +- `MockSoilDataAdapter` + +#### منطق انتخاب provider + +مقدار provider از این setting خوانده می‌شود: + +```python +settings.SOIL_DATA_PROVIDER +``` + +اگر وجود نداشته باشد، مقدار پیش‌فرض: + +```python +"mock" +``` + +#### حالت اول: `provider == "soilgrids"` + +در این حالت: + +- از `SoilGridsAdapter` استفاده می‌شود +- timeout آن از این setting می‌آید: + +```python +settings.SOILGRIDS_TIMEOUT_SECONDS +``` + +اگر این setting هم نباشد، مقدار پیش‌فرض: + +```python +60 +``` + +یعنی درخواست به provider واقعی SoilGrids حداکثر 60 ثانیه صبر می‌کند. + +#### حالت دوم: `provider == "mock"` + +در این حالت: + +- از `MockSoilDataAdapter` استفاده می‌شود +- delay آن از این setting می‌آید: + +```python +settings.SOIL_MOCK_DELAY_SECONDS +``` + +اگر این setting هم نباشد، مقدار پیش‌فرض: + +```python +0.8 +``` + +یعنی adapter تستی/نمایشی با تاخیر مصنوعی 0.8 ثانیه کار می‌کند. + +#### حالت نامعتبر + +اگر `SOIL_DATA_PROVIDER` چیزی غیر از `soilgrids` یا `mock` باشد: + +- `ValueError` رخ می‌دهد +- یعنی config پروژه اشتباه است و provider شناخته نشده + +--- + +## ارتباط `location_data/apps.py` با فیلدهای واقعی داده + +این فایل خودش مدل تعریف نمی‌کند، اما به‌صورت مستقیم برای کار با مدل‌های اپ `location_data` استفاده می‌شود؛ مهم‌ترین آن‌ها این‌ها هستند: + +- `location_data.models.SoilLocation` +- `location_data.models.SoilDepthData` +- `location_data.models.NdviObservation` + +### فیلدهای مهم `SoilLocation` + +#### `latitude` + +- عرض جغرافیایی مرکز زمین +- نوع آن `DecimalField` است +- روی آن index وجود دارد +- این نقطه معمولاً مرکز هندسی مزرعه است، نه لزوماً یکی از گوشه‌های مرز + +#### `longitude` + +- طول جغرافیایی مرکز زمین +- مثل `latitude` برای lookup و resolve کردن داده‌های خاک استفاده می‌شود + +#### `task_id` + +- شناسه تسک Celery برای پردازش‌های async +- وقتی fetch داده خاک یا پردازش مرتبط در صف باشد، می‌توان با این فیلد وضعیت را track کرد + +#### `farm_boundary` + +- مرز مزرعه را به‌صورت JSON نگه می‌دارد +- معمولاً به‌شکل `Polygon` یا ساختار corner-based ذخیره می‌شود +- این فیلد خیلی مهم است چون فقط یک نقطه center نگه نمی‌دارید، بلکه شکل کلی زمین هم ثبت می‌شود + +#### `created_at` / `updated_at` + +- زمان ایجاد و آخرین به‌روزرسانی رکورد + +### propertyهای مهم `SoilLocation` + +#### `center_latitude` + +- فقط alias برای `latitude` است + +#### `center_longitude` + +- فقط alias برای `longitude` است + +#### `is_complete` + +- بررسی می‌کند آیا هر سه لایه خاک برای این location ثبت شده‌اند یا نه +- شرط آن این است که تعداد `depths` دقیقاً 3 باشد + +### فیلدهای مهم `SoilDepthData` + +این مدل برای هر location سه رکورد عمق خاک نگه می‌دارد: + +- `0-5cm` +- `5-15cm` +- `15-30cm` + +فیلدهای اصلی: + +- `soil_location`: ارتباط به `SoilLocation` +- `depth_label`: مشخص می‌کند داده برای کدام عمق است +- `bdod`: چگالی ظاهری خاک +- `cec`: ظرفیت تبادل کاتیونی +- `cfvo`: حجم قطعات درشت خاک +- `clay`: درصد رس +- `nitrogen`: مقدار نیتروژن +- `ocd` و `ocs`: شاخص‌های کربن آلی +- `phh2o`: pH خاک +- `sand`: درصد شن +- `silt`: درصد سیلت +- `soc`: کربن آلی خاک +- `wv0010`: رطوبت حجمی در فشار 10 kPa +- `wv0033`: رطوبت در حدود ظرفیت زراعی +- `wv1500`: رطوبت در نقطه پژمردگی دائم + +این فیلدها برای شبیه‌سازی، آبیاری، و تخمین وضعیت واقعی خاک مهم هستند. + +### فیلدهای مهم `NdviObservation` + +- `location`: ارتباط با `SoilLocation` +- `observation_date`: تاریخ مشاهده +- `mean_ndvi`: میانگین NDVI +- `ndvi_map`: داده مکانی NDVI +- `vegetation_health_class`: کلاس سلامت پوشش گیاهی +- `satellite_source`: منبع تصویر مثل `sentinel-2` +- `cloud_cover`: درصد پوشش ابر +- `metadata`: داده تکمیلی + +--- + +## نکته مهم: grid بندی زمین انجام می‌شود + +بله، در لایه داده و سنجش از دور، مفهوم grid بندی وجود دارد. + +اما این grid بندی در پروژه بیشتر در این دو سطح دیده می‌شود: + +### 1) grid در NDVI map + +در `location_data/remote_sensing.py` داده NDVI به‌صورت grid محاسبه و ذخیره می‌شود. + +یعنی: + +- تصویر ماهواره‌ای به خانه‌های کوچک‌تر تقسیم می‌شود +- برای هر خانه مقدار NDVI محاسبه می‌شود +- خروجی در `ndvi_map` معمولاً به‌شکل grid نگه‌داری می‌شود + +این یعنی وضعیت سلامت گیاه فقط به‌صورت یک عدد کلی نیست، بلکه می‌تواند روی بخش‌های مختلف زمین map شود. + +### 2) grid/cell در adapter خاک + +در `location_data/soil_adapters.py` هم منطق cell/grid دیده می‌شود، مخصوصاً در adapterهای mock یا interpolation-based. + +یعنی: + +- مختصات lat/lon به cellهای شبکه‌ای نگاشت می‌شود +- در بعضی محاسبات از `grid_x` و `grid_y` استفاده می‌شود +- این کمک می‌کند داده خاک برای ناحیه‌های نزدیک، رفتار مکانی منطقی‌تری داشته باشد + +### نتیجه مهم + +خود مدل `SoilLocation` یک مرکز زمین را نگه می‌دارد، ولی مرز مزرعه و NDVI grid باعث می‌شوند سیستم فقط point-based نباشد. + +یعنی: + +- مرکز زمین برای lookup سریع و اتصال به داده خاک/هوا استفاده می‌شود +- مرز مزرعه برای شکل واقعی زمین ذخیره می‌شود +- grid بندی برای تحلیل مکانی، مخصوصاً در NDVI، انجام می‌شود + +--- + +## مرکز زمین چطور از مرز مزرعه به‌دست می‌آید + +در `farm_data/services.py` از روی `farm_boundary`، مرکز هندسی polygon محاسبه می‌شود. + +پس flow کلی این‌طور است: + +1. مرز مزرعه ارسال می‌شود +2. polygon نرمال می‌شود +3. centroid هندسی آن محاسبه می‌شود +4. یک `SoilLocation` برای center ساخته یا پیدا می‌شود +5. بعد داده خاک، NDVI و هوا به این location متصل می‌شوند + +پس زمین فقط با یک نقطه خام ثبت نمی‌شود؛ اول مرز دارد، بعد center از روی آن به‌دست می‌آید. + +--- + +## متدهای کمکی `location_data/apps.py` + +### `get_ndvi_health_service()` + +- خروجی `self.ndvi_health_service` را برمی‌گرداند +- یک accessor ساده برای گرفتن سرویس NDVI است + +### `get_soil_data_adapter()` + +- خروجی `self.soil_data_adapter` را برمی‌گرداند +- بقیه بخش‌های پروژه از این متد برای گرفتن adapter فعال استفاده می‌کنند +/ +--- + +## فیلدها و settingهای مهم مرتبط با `location_data/apps.py` + +### فیلدهای AppConfig + +- `default_auto_field`: نوع primary key پیش‌فرض مدل‌ها +- `name`: نام داخلی اپ +- `verbose_name`: نام نمایشی اپ + +### settingهای استفاده‌شده + +- `SOIL_DATA_PROVIDER`: انتخاب provider فعال خاک +- `SOILGRIDS_TIMEOUT_SECONDS`: timeout برای provider واقعی SoilGrids +- `SOIL_MOCK_DELAY_SECONDS`: تاخیر مصنوعی برای provider mock + +--- + +## 2) فایل `farm_data/apps.py` + +این فایل AppConfig مربوط به اپ `farm_data` را تعریف می‌کند. + +کلاس اصلی: + +```python +class FarmDataConfig(AppConfig): +``` + +### فیلدها + +#### `default_auto_field = "django.db.models.BigAutoField"` + +- مثل اپ قبلی، تعیین می‌کند primary key پیش‌فرض مدل‌های این اپ از نوع `BigAutoField` باشد. + +#### `name = "farm_data"` + +- نام داخلی و ماژول اپ Django است. +- برای شناسایی اپ در `INSTALLED_APPS` و registry داخلی Django استفاده می‌شود. + +#### `label = "sensor_data"` + +- label داخلی اپ در registry Django است. +- این فیلد زمانی مهم می‌شود که: + - بخواهید نام registry اپ با `name` فرق داشته باشد + - یا از تداخل نام اپ‌ها جلوگیری کنید +- در این پروژه، اپ `farm_data` با label داخلی `sensor_data` شناخته می‌شود. + +نکته: + +- `label` باید در کل پروژه یکتا باشد. +- این مقدار ممکن است در migrationها، relationها یا lookupهای app registry اثر داشته باشد. + +#### `verbose_name = "farm-data"` + +- نام نمایشی اپ است. +- بیشتر برای admin و نمایش انسانی استفاده می‌شود. + +--- + +## نکته مهم درباره `farm_data/apps.py` + +برخلاف `location_data/apps.py`، این فایل: + +- `cached_property` ندارد +- service locator ندارد +- adapter یا provider انتخاب نمی‌کند + +یعنی فعلاً فقط نقش config پایه اپ را دارد. + +--- + +## ارتباط `farm_data/apps.py` با فیلدهای واقعی داده + +این app بیشتر داده‌های farm-level و sensor-level را نگه می‌دارد. مهم‌ترین مدل‌هایش: + +- `farm_data.models.SensorData` +- `farm_data.models.SensorParameter` +- `farm_data.models.ParameterUpdateLog` + +### فیلدهای مهم `SensorData` + +#### `farm_uuid` + +- شناسه یکتای مزرعه +- primary key این مدل است +- هر رکورد `SensorData` نماینده یک مزرعه است + +#### `center_location` + +- ارتباط به `location_data.SoilLocation` +- یعنی این مزرعه به یک location مرکزی وصل است +- از همین نقطه مرکزی برای weather/soil/simulation استفاده می‌شود + +#### `weather_forecast` + +- ارتباط اختیاری با `weather.WeatherForecast` +- اگر موجود باشد، forecast منتخب یا آخرین forecast به مزرعه وصل می‌شود + +#### `sensor_payload` + +- مهم‌ترین فیلد این مدل است +- داده سنسورها به‌صورت JSON نگه‌داری می‌شود +- ساختار معمول آن شبیه این است: + +```json +{ + "sensor-7-1": { + "soil_moisture": 25.5, + "soil_temperature": 22.3, + "soil_ph": 7.2 + } +} +``` + +مزیت این ساختار: + +- چند سنسور در یک مزرعه پشتیبانی می‌شود +- هر سنسور می‌تواند فیلدهای خاص خودش را داشته باشد +- schema سنسورها rigid نیست + +#### `plants` + +- رابطه چندبه‌چند با `plant.Plant` +- یعنی یک farm می‌تواند چند گیاه مرتبط داشته باشد + +#### `irrigation_method` + +- روش آبیاری انتخاب‌شده برای مزرعه +- برای recommendation و planning مهم است + +#### `created_at` / `updated_at` + +- زمان ایجاد و آخرین ویرایش رکورد + +### propertyهای مهم `SensorPayloadMixin` + +مدل `SensorData` از `SensorPayloadMixin` ارث می‌گیرد و این helperها را دارد: + +#### `_payload()` + +- payload را فقط وقتی dict معتبر باشد برمی‌گرداند + +#### `get_sensor_block(sensor_key=None)` + +- اگر `sensor_key` بدهید، همان بلوک سنسور را برمی‌گرداند +- اگر ندهید، اولین بلوک معتبر را برمی‌گرداند + +#### `get_metric(metric_name, sensor_key=None)` + +- یک metric خاص را از payload پیدا می‌کند +- اول در sensor مشخص‌شده می‌گردد +- اگر پیدا نشد، در بقیه blockها جستجو می‌کند + +#### propertyهای آماده + +این propertyها shortcut هستند: + +- `soil_moisture` +- `soil_temperature` +- `soil_ph` +- `electrical_conductivity` +- `nitrogen` +- `phosphorus` +- `potassium` + +یعنی به‌جای parse دستی JSON، مستقیم می‌توان این متریک‌ها را خواند. + +### فیلدهای مهم `SensorParameter` + +این مدل dictionary پارامترهای سنسور را نگه می‌دارد. + +#### `sensor_key` + +- کلید سنسور مثل `sensor-7-1` + +#### `code` + +- کد پارامتر مثل `soil_moisture` + +#### `name_fa` + +- نام فارسی پارامتر + +#### `unit` + +- واحد پارامتر مثل `%` یا `dS/m` + +#### `data_type` + +- نوع داده مثل `float`, `int`, `string`, `bool` + +#### `metadata` + +- داده تکمیلی برای UI یا validation +- مثلاً: + - بازه مجاز + - توضیح + - تنظیمات نمایش + +### فیلدهای مهم `ParameterUpdateLog` + +- `parameter`: ارتباط به `SensorParameter` +- `action`: نوع عملیات مثل `added` یا `modified` +- `payload`: خلاصه تغییرات +- `updated_at`: زمان ثبت لاگ + +این مدل برای audit و پیگیری تغییرات پارامترها مفید است. + +--- + +## جمع‌بندی + +### `location_data/apps.py` + +- هم metadata اپ را نگه می‌دارد +- هم سرویس و adapter در اختیار پروژه می‌گذارد +- هم از settingها برای انتخاب provider واقعی یا mock استفاده می‌کند +- و در عمل با location center، مرز مزرعه، داده لایه‌های خاک و gridهای NDVI کار می‌کند + +### `farm_data/apps.py` + +- فقط config پایه AppConfig را تعریف می‌کند +- نقش آن بیشتر register کردن اپ با نام و label مشخص است +- اما داده‌های اصلی مزرعه مثل `farm_uuid`، `sensor_payload`، گیاه، روش آبیاری و اتصال به center location در مدل‌های همین app نگه‌داری می‌شوند diff --git a/Modules/Ai/docs/location_data_current_structure.md b/Modules/Ai/docs/location_data_current_structure.md new file mode 100644 index 0000000..e08a523 --- /dev/null +++ b/Modules/Ai/docs/location_data_current_structure.md @@ -0,0 +1,371 @@ +# ساختار فعلی `location_data` + +این فایل وضعیت فعلی اپ `location_data` را توضیح می‌دهد؛ هم از نظر ساختار فایل‌ها و هم از نظر مدل‌ها، APIها و جریان داده. + +## هدف فعلی اپ + +اپ `location_data` فعلاً این مسئولیت‌ها را دارد: + +- نگه‌داری موقعیت زمین با `lat` و `lon` +- نگه‌داری مرز مزرعه در `farm_boundary` +- نگه‌داری ساختار بلوک‌های زمین در `block_layout` +- نگه‌داری داده‌های خاک برای عمق‌های مختلف در `SoilDepthData` +- نگه‌داری مشاهدات NDVI در `NdviObservation` +- برگرداندن ساختار بلوک‌های زمین از API محلی بدون نیاز به API خارجی در فاز فعلی + +نکته مهم: + +- در فاز فعلی، endpoint اصلی `location_data` برای ساختار زمین، فقط داده را در دیتابیس می‌خواند/ذخیره می‌کند. +- فعلاً برای بلوک‌ها، زیر‌بلوک‌ها و داده‌های ماهواره‌ای هیچ درخواست خارجی زده نمی‌شود. + +## ساختار فایل‌ها + +```text +location_data/ +├── admin.py +├── apps.py +├── models.py +├── serializers.py +├── views.py +├── urls.py +├── tasks.py +├── soil_adapters.py +├── remote_sensing.py +├── ndvi.py +├── test_soil_api.py +├── test_soil_adapters.py +├── test_ndvi_health_api.py +├── postman/ +│ └── soil_data.json +├── management/ +│ └── commands/ +└── migrations/ + ├── 0001_initial.py + ├── 0002_soildepthdata_refactor.py + ├── 0002_soillocation_ideal_sensor_profile.py + ├── 0003_rename_app_label.py + ├── 0004_soillocation_farm_boundary.py + ├── 0005_merge_20260327_0840.py + ├── 0006_remove_soillocation_ideal_sensor_profile.py + ├── 0007_ndviobservation.py + └── 0008_soillocation_block_layout.py +``` + +## مدل‌ها و جدول‌ها + +### 1) `SoilLocation` + +مدل اصلی اپ است و نماینده یک موقعیت یکتا برای زمین یا مرکز زمین محسوب می‌شود. + +فیلدهای اصلی: + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `BigAutoField` | شناسه داخلی رکورد | +| `latitude` | `DecimalField(9,6)` | عرض جغرافیایی | +| `longitude` | `DecimalField(9,6)` | طول جغرافیایی | +| `task_id` | `CharField` | شناسه تسک Celery برای جریان قدیمی واکشی خاک | +| `farm_boundary` | `JSONField` | مرز مزرعه به شکل Polygon یا corners | +| `input_block_count` | `PositiveIntegerField` | تعداد بلوک اولیه‌ای که از ورودی کشاورز می‌آید | +| `block_layout` | `JSONField` | ساختار بلوک‌ها و زیر‌بلوک‌های زمین | +| `created_at` | `DateTimeField` | زمان ایجاد | +| `updated_at` | `DateTimeField` | زمان آخرین تغییر | + +قیدها: + +- روی ترکیب `latitude` و `longitude` یکتا است. + +رفتار مهم: + +- اگر `input_block_count` ارسال نشود، مقدار پیش‌فرض `1` است. +- اگر `block_layout` خالی باشد، به صورت خودکار با یک بلوک کامل ساخته می‌شود. +- متد `set_input_block_count()` ساختار اولیه بلوک‌ها را می‌سازد. + +### 2) `SoilDepthData` + +این مدل داده‌های خاک را برای هر عمق نگه می‌دارد و به `SoilLocation` وصل است. + +عمق‌های فعلی: + +- `0-5cm` +- `5-15cm` +- `15-30cm` + +فیلدهای مهم: + +| فیلد | نوع | توضیح | +|---|---|---| +| `soil_location` | `ForeignKey` | ارتباط با `SoilLocation` | +| `depth_label` | `CharField` | برچسب عمق | +| `bdod` تا `wv1500` | `FloatField` | پارامترهای مختلف خاک | +| `created_at` | `DateTimeField` | زمان ثبت رکورد | + +قیدها: + +- برای هر `soil_location` و هر `depth_label` فقط یک رکورد وجود دارد. + +### 3) `NdviObservation` + +این مدل برای ذخیره مشاهده‌های NDVI استفاده می‌شود. + +فیلدهای مهم: + +| فیلد | نوع | توضیح | +|---|---|---| +| `location` | `ForeignKey` | ارتباط با `SoilLocation` | +| `observation_date` | `DateField` | تاریخ مشاهده | +| `mean_ndvi` | `FloatField` | میانگین NDVI | +| `ndvi_map` | `JSONField` | داده مکانی NDVI | +| `vegetation_health_class` | `CharField` | کلاس سلامت پوشش گیاهی | +| `satellite_source` | `CharField` | منبع تصویر ماهواره‌ای | +| `cloud_cover` | `FloatField` | درصد ابر | +| `metadata` | `JSONField` | داده تکمیلی | + +## ساختار `block_layout` + +فیلد `block_layout` فعلاً ساختار پایه تقسیم زمین را نگه می‌دارد. + +نمونه پیش‌فرض وقتی کل زمین یک بلوک باشد: + +```json +{ + "input_block_count": 1, + "default_full_farm": true, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "needs_subdivision": null, + "sub_blocks": [] + } + ] +} +``` + +نمونه وقتی ورودی مثلاً `block_count = 3` باشد: + +```json +{ + "input_block_count": 3, + "default_full_farm": false, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "input", + "needs_subdivision": null, + "sub_blocks": [] + }, + { + "block_code": "block-2", + "order": 2, + "source": "input", + "needs_subdivision": null, + "sub_blocks": [] + }, + { + "block_code": "block-3", + "order": 3, + "source": "input", + "needs_subdivision": null, + "sub_blocks": [] + } + ] +} +``` + +معنای فیلدها: + +| فیلد | توضیح | +|---|---| +| `input_block_count` | تعداد بلوک اولیه | +| `default_full_farm` | آیا کل زمین هنوز یک بلوک کامل است یا نه | +| `algorithm_status` | وضعیت اجرای الگوریتم تقسیم‌بندی | +| `blocks` | لیست بلوک‌های فعلی | +| `block_code` | کد بلوک | +| `order` | ترتیب بلوک | +| `source` | منشأ بلوک: `default` یا `input` | +| `needs_subdivision` | آیا الگوریتم تشخیص داده که این بلوک باید خردتر شود یا نه | +| `sub_blocks` | لیست زیر‌بلوک‌ها | + +## Serializerها + +### `SoilDataRequestSerializer` + +ورودی endpoint اصلی `location_data`: + +| فیلد | اجباری | توضیح | +|---|---|---| +| `lat` | بله | عرض جغرافیایی | +| `lon` | بله | طول جغرافیایی | +| `block_count` | خیر | تعداد بلوک اولیه، پیش‌فرض `1` | + +### `SoilLocationResponseSerializer` + +خروجی اصلی برای یک location: + +- `id` +- `lat` +- `lon` +- `input_block_count` +- `block_layout` +- `depths` + +### `SoilDepthDataSerializer` + +لیست پارامترهای خاک برای هر عمق را برمی‌گرداند. + +### `NdviHealthRequestSerializer` و `NdviHealthResponseSerializer` + +برای endpoint مربوط به NDVI استفاده می‌شوند. + +## Viewها و APIها + +### 1) `SoilDataView` + +مسیر: + +- `GET /api/soil-data/` +- `POST /api/soil-data/` + +وظیفه فعلی: + +- گرفتن `lat` و `lon` +- گرفتن `block_count` در صورت وجود +- ساخت یا پیدا کردن `SoilLocation` +- ذخیره `input_block_count` +- ساخت `block_layout` +- برگرداندن پاسخ با `source = local` + +رفتار فعلی: + +- اگر location وجود نداشته باشد، ساخته می‌شود. +- اگر `block_count` تغییر کند، ساختار `block_layout` دوباره ساخته می‌شود. +- فعلاً هیچ fetch خارجی برای اطلاعات خاک یا ماهواره‌ای انجام نمی‌شود. + +### 2) `SoilDataTaskStatusView` + +مسیر: + +- `GET /api/soil-data/tasks//status/` + +وضعیت فعلی: + +- هنوز در کد وجود دارد. +- برای جریان قدیمی مبتنی بر Celery طراحی شده است. +- با تغییر اخیر، endpoint اصلی `location_data` دیگر به‌طور پیش‌فرض task جدیدی صف نمی‌کند. + +### 3) `NdviHealthView` + +مسیر: + +- `POST /api/soil-data/ndvi-health/` + +وظیفه: + +- دریافت `farm_uuid` +- خواندن داده NDVI از سرویس داخلی NDVI +- برگرداندن اطلاعات سلامت پوشش گیاهی + +## فایل `tasks.py` + +این فایل هنوز منطق قدیمی واکشی داده خاک را نگه می‌دارد. + +اجزای اصلی: + +- `fetch_soil_data_for_coordinates()` +- `fetch_soil_data_task()` + +نکته: + +- این بخش هنوز برای سازگاری و جریان‌های قدیمی در پروژه باقی مانده است. +- ولی در فاز فعلی تقسیم بلوک‌ها، از این task برای endpoint اصلی `location_data` استفاده نمی‌شود. + +## فایل `soil_adapters.py` + +این فایل abstraction مربوط به تامین داده خاک را نگه می‌دارد. + +کاربرد آن: + +- mock provider +- live soil provider +- ساختار depth-based data fetch + +در وضعیت فعلی: + +- برای منطق بلوک‌بندی فعلی لازم نیست. +- اما برای جریان قدیمی یا مراحل بعدی می‌تواند دوباره استفاده شود. + +## فایل `remote_sensing.py` + +این فایل مربوط به منطق سنجش‌ازدور و داده‌های ماهواره‌ای است. + +در وضعیت فعلی: + +- برای block layout فعلاً استفاده فعال ندارد. +- بعداً می‌تواند برای تحلیل هر بلوک یا زیر‌بلوک استفاده شود. + +## فایل `ndvi.py` + +این فایل سرویس/منطق NDVI را نگه می‌دارد و برای endpoint NDVI استفاده می‌شود. + +## migrationها + +مهم‌ترین migrationهای فعلی: + +| migration | توضیح | +|---|---| +| `0001_initial.py` | ساختار اولیه `SoilLocation` | +| `0002_soildepthdata_refactor.py` | جداسازی داده‌های عمقی در `SoilDepthData` | +| `0004_soillocation_farm_boundary.py` | اضافه شدن `farm_boundary` | +| `0007_ndviobservation.py` | اضافه شدن `NdviObservation` | +| `0008_soillocation_block_layout.py` | اضافه شدن `input_block_count` و `block_layout` | + +## تست‌ها + +فایل‌های تست اصلی: + +- `location_data/test_soil_api.py` + - تست ساختار محلی بلوک‌ها + - تست پیش‌فرض یک بلوک + - تست تغییر `block_count` + +- `location_data/test_soil_adapters.py` + - تست adapterهای خاک + - تست ذخیره depth data + +- `location_data/test_ndvi_health_api.py` + - تست endpoint NDVI + +## ارتباط با `farm_data` + +`location_data` مستقیماً توسط `farm_data` استفاده می‌شود. + +نمونه وابستگی‌ها: + +- `farm_data` از `SoilLocation` به عنوان `center_location` استفاده می‌کند. +- `farm_boundary` از سمت `farm_data` می‌آید. +- `block_count` هم از ورودی `farm_data` قابل ثبت است. +- `farm_data` فعلاً فقط location و block layout را ذخیره می‌کند و برای این بخش sync خارجی انجام نمی‌دهد. + +## جمع‌بندی ساختار فعلی + +الان `location_data` دو لایه دارد: + +1. لایه فعلی فعال برای بلوک‌بندی زمین + - محلی + - ساده + - بدون API خارجی + - با `input_block_count` و `block_layout` + +2. لایه قدیمی/جانبی برای خاک و NDVI + - `SoilDepthData` + - `tasks.py` + - `soil_adapters.py` + - `NdviObservation` + - `remote_sensing.py` + +یعنی از نظر معماری، اپ الان هم داده مکانی زمین را نگه می‌دارد و هم زیرساختی برای تحلیل خاک/NDVI دارد، ولی منطق جدید بلوک‌ها فعلاً مستقل و محلی پیاده شده است. diff --git a/Modules/Ai/docs/location_data_current_workflow.md b/Modules/Ai/docs/location_data_current_workflow.md new file mode 100644 index 0000000..99e32fc --- /dev/null +++ b/Modules/Ai/docs/location_data_current_workflow.md @@ -0,0 +1,685 @@ +# مستند کامل عملکرد فعلی `location_data` + +این فایل شرح می‌دهد که اپ `location_data` در وضعیت فعلی دقیقاً چه کاری انجام می‌دهد، چه مدل‌هایی دارد، جریان درخواست‌ها چگونه است، منطق تقسیم‌بندی بلوک‌ها چگونه اجرا می‌شود و چه بخش‌هایی فقط داده ذخیره‌شده را برمی‌گردانند. + +--- + +## 1) هدف فعلی اپ `location_data` + +اپ `location_data` در وضعیت فعلی چند مسئولیت اصلی دارد: + +- نگه‌داری موقعیت جغرافیایی زمین با `lat` و `lon` +- نگه‌داری مرز زمین یا بلوک در `farm_boundary` +- نگه‌داری ساختار بلوک‌های اصلی زمین در `block_layout` +- نگه‌داری نتیجه خردسازی هوشمند هر بلوک در مدل `BlockSubdivision` +- تولید نقاط شبکه‌ای 100 متری یا هر اندازه‌ای که با `SUBDIVISION_CHUNK_SQM` تنظیم شود +- اجرای خوشه‌بندی `KMeans` روی نقاط شبکه‌ای +- پیدا کردن تعداد بهینه خوشه‌ها با روش `Elbow` +- ذخیره centroidهای نهایی هر بخش خردشده +- تولید و ذخیره تصویر نمودار `K-SSE` برای هر subdivision +- نگه‌داری داده‌های خاک در `SoilDepthData` +- نگه‌داری داده‌های NDVI در `NdviObservation` + +نکته مهم: + +- در فاز فعلی، `GET` هیچ پردازش جدیدی انجام نمی‌دهد. +- تمام پردازش subdivision فقط در زمان `POST` و فقط اگر subdivision آن بلوک قبلاً ساخته نشده باشد اجرا می‌شود. + +--- + +## 2) تنظیمات محیطی + +### `SUBDIVISION_CHUNK_SQM` + +در `config/settings.py` یک متغیر جدید اضافه شده است: + +- `SUBDIVISION_CHUNK_SQM` +- مقدار پیش‌فرض: `100` +- واحد: متر مربع + +کاربرد: + +- تعیین می‌کند شبکه اولیه برای subdivision با چه اندازه‌ای ساخته شود. +- اگر مقدار `100` باشد، هر chunk تقریباً یک سلول `10m x 10m` خواهد بود، چون: + +```text +step = sqrt(100) = 10 meters +``` + +این مقدار از `.env` یا environment خوانده می‌شود: + +```env +SUBDIVISION_CHUNK_SQM=100 +``` + +--- + +## 3) مدل‌های اصلی اپ + +## 3.1) `SoilLocation` + +این مدل رکورد اصلی location را نگه می‌دارد. + +### فیلدها + +- `latitude` +- `longitude` +- `task_id` +- `farm_boundary` +- `input_block_count` +- `block_layout` +- `created_at` +- `updated_at` + +### نقش + +- هر location با ترکیب `latitude + longitude` یکتا است. +- اطلاعات کلی زمین یا مرکز زمین را نگه می‌دارد. +- اگر هنوز هیچ تقسیم‌بندی انجام نشده باشد، ساختار اولیه بلوک‌ها را در `block_layout` نگه می‌دارد. + +### `block_layout` + +این فیلد JSON ساختار بلوک‌ها را نگه می‌دارد. نمونه ساده: + +```json +{ + "input_block_count": 1, + "default_full_farm": true, + "algorithm_status": "completed", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "needs_subdivision": true, + "sub_blocks": [ + { + "sub_block_code": "sub-block-1", + "centroid_lat": 35.689123, + "centroid_lon": 51.389456 + } + ], + "subdivision_summary": { + "chunk_size_sqm": 100, + "grid_point_count": 24, + "centroid_count": 3, + "optimal_k": 3 + } + } + ] +} +``` + +### رفتار مهم + +- اگر `block_layout` خالی باشد، به صورت پیش‌فرض با یک بلوک کامل ساخته می‌شود. +- متد `set_input_block_count()` ساختار اولیه بلوک‌های اصلی را می‌سازد. + +--- + +## 3.2) `BlockSubdivision` + +این مدل نتیجه واقعی subdivision برای هر بلوک را ذخیره می‌کند. + +### فیلدها + +- `soil_location`: ارتباط با `SoilLocation` +- `block_code`: شناسه بلوکی که subdivision روی آن اجرا شده +- `source_boundary`: مرز همان بلوک +- `chunk_size_sqm`: اندازه هر chunk +- `grid_points`: نقاط اولیه شبکه +- `centroid_points`: centroidهای نهایی خوشه‌ها +- `grid_point_count`: تعداد نقاط اولیه +- `centroid_count`: تعداد centroidهای نهایی +- `elbow_plot`: تصویر نمودار elbow +- `status`: وضعیت رکورد +- `metadata`: داده تکمیلی مانند `optimal_k` و `inertia_curve` +- `created_at` +- `updated_at` + +### نقش + +این مدل منبع اصلی داده subdivision است. + +یعنی: + +- نقاط خام شبکه در این مدل ذخیره می‌شوند +- centroidهای نهایی هم در این مدل ذخیره می‌شوند +- نمودار elbow هم در همین مدل ذخیره می‌شود + +### قید یکتا + +برای هر location و هر `block_code` فقط یک subdivision وجود دارد: + +```text +(soil_location, block_code) unique +``` + +بنابراین اگر برای یک بلوک قبلاً subdivision ساخته شده باشد، دوباره ایجاد نمی‌شود. + +--- + +## 3.3) `SoilDepthData` + +این مدل داده‌های خاک برای عمق‌های مختلف را نگه می‌دارد. + +عمق‌های فعلی: + +- `0-5cm` +- `5-15cm` +- `15-30cm` + +این بخش در حال حاضر مستقل از subdivision است و هنوز برای هر sub-block جداگانه داده خاک تولید نمی‌کند. + +--- + +## 3.4) `NdviObservation` + +این مدل داده‌های NDVI و سلامت پوشش گیاهی را نگه می‌دارد. + +این بخش هم فعلاً مستقل از منطق subdivision است. + +--- + +## 4) فایل `block_subdivision.py` + +فایل `location_data/block_subdivision.py` مرکز اصلی منطق هوشمند subdivision است. + +### وظایف اصلی این فایل + +- استخراج polygon از ورودی +- تبدیل مختصات جغرافیایی به صفحه محلی متری +- ساخت grid points با اندازه chunk مشخص +- اجرای `KMeans` برای `K=1..10` +- ذخیره `SSE` یا همان `Inertia` +- پیدا کردن elbow point +- ساخت centroidهای نهایی خوشه‌ها +- sync کردن نتیجه با `block_layout` +- تولید تصویر نمودار elbow +- ذخیره تصویر در مدل با `ContentFile` + +--- + +## 5) روند هندسی subdivision + +## 5.1) استخراج Polygon + +ورودی boundary می‌تواند به چند شکل بیاید: + +- GeoJSON Polygon +- `corners` +- آرایه مستقیم از نقاط + +تابع `extract_polygon()` این ورودی را به لیستی از نقاط جغرافیایی تبدیل می‌کند. + +نمونه ورودی معتبر: + +```json +{ + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3902, 35.6890], + [51.3902, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890] + ] + ] +} +``` + +--- + +## 5.2) تبدیل مختصات به فضای محلی متری + +برای اینکه بتوانیم فاصله‌ها و گریدبندی را بر اساس متر حساب کنیم، polygon از مختصات جغرافیایی به مختصات محلی متری تبدیل می‌شود. + +تابع مربوط: + +- `project_polygon_to_local_meters()` + +ویژگی این تبدیل: + +- نقطه اول polygon به عنوان origin در نظر گرفته می‌شود +- با تقریب محلی، `lat/lon` به `x/y` در واحد متر تبدیل می‌شوند + +این تبدیل برای subdivision کوچک و محلی مناسب است. + +--- + +## 5.3) تولید grid points + +تابع: + +- `generate_grid_points()` + +منطق: + +1. ابتدا اندازه گام محاسبه می‌شود: + +```text +step_m = sqrt(chunk_size_sqm) +``` + +2. روی bounding box polygon، نقاط مرکزی grid بررسی می‌شوند. +3. هر نقطه‌ای که داخل polygon باشد نگه داشته می‌شود. + +خروجی: + +- `grid_points`: مختصات جغرافیایی قابل ذخیره در JSON +- `grid_vectors`: مختصات محلی متری برای ورود به `KMeans` + +نمونه هر grid point: + +```json +{ + "point_code": "pt-1", + "lat": 35.689123, + "lon": 51.389456 +} +``` + +--- + +## 6) الگوریتم خوشه‌بندی هوشمند + +## 6.1) اجرای `KMeans` + +تابع: + +- `cluster_grid_points()` + +منطق: + +- روی `grid_vectors` خوشه‌بندی انجام می‌شود +- برای `K=1` تا `K=10` اجرا می‌شود +- اگر تعداد نقاط کمتر از 10 باشد، `max_k = len(grid_vectors)` در نظر گرفته می‌شود + +برای هر `K`: + +- مدل `KMeans` ساخته می‌شود +- `fit()` اجرا می‌شود +- مقدار `model.inertia_` به عنوان `SSE` ذخیره می‌شود + +خروجی میانی: + +```json +[ + {"k": 1, "sse": 1300.5}, + {"k": 2, "sse": 640.2}, + {"k": 3, "sse": 390.1} +] +``` + +--- + +## 6.2) پیدا کردن Elbow Point + +تابع: + +- `detect_elbow_point()` + +منطق فعلی: + +1. از روی SSEها، شیب افت بین نقاط متوالی محاسبه می‌شود. +2. سپس تغییرات شیب محاسبه می‌شود. +3. هر جایی که افت شیب ناگهان متوقف شود، همان نقطه elbow در نظر گرفته می‌شود. + +یعنی در عمل: + +- ابتدا `slopes` محاسبه می‌شود +- سپس اختلاف شیب‌ها بررسی می‌شود +- بیشترین تغییر شیب به عنوان elbow انتخاب می‌شود + +خروجی: + +- `optimal_k` + +--- + +## 6.3) تولید centroidهای نهایی + +بعد از پیدا شدن `optimal_k`: + +- مدل `KMeans` همان `K` نهایی انتخاب می‌شود +- مختصات مراکز خوشه‌ها (`cluster_centers_`) گرفته می‌شود +- از فضای متری به `lat/lon` تبدیل می‌شود +- در `centroid_points` ذخیره می‌شود + +نمونه centroid: + +```json +{ + "sub_block_code": "sub-block-1", + "centroid_lat": 35.689321, + "centroid_lon": 51.389789 +} +``` + +این centroidها در عمل همان مراکز بخش‌های کوچکتر زمین هستند. + +--- + +## 7) تولید و ذخیره نمودار Elbow + +### تابع + +- `render_elbow_plot()` + +### منطق + +پس از محاسبه `inertia_curve` و `optimal_k`: + +1. نمودار `K` در برابر `SSE` رسم می‌شود +2. نقطه elbow با رنگ قرمز مشخص می‌شود +3. تصویر به صورت PNG در `BytesIO` ذخیره می‌شود +4. با `ContentFile` به `ImageField` مدل `BlockSubdivision` داده می‌شود + +### نکته مهم حافظه + +برای جلوگیری از memory leak: + +- از backend غیرتعاملی `Agg` استفاده می‌شود +- بعد از ذخیره تصویر، `plt.close(fig)` اجرا می‌شود +- buffer هم بسته می‌شود + +این برای پردازش‌های همزمان سرور ضروری است. + +--- + +## 8) جریان کامل `POST /api/soil-data/` + +این endpoint الان مهم‌ترین ورودی subdivision است. + +### ورودی‌های قابل پشتیبانی + +- `lat` +- `lon` +- `block_count` +- `block_code` +- `farm_boundary` + +### سناریوی اجرا + +#### مرحله 1: اعتبارسنجی ورودی + +سریالایزر `SoilDataRequestSerializer` داده را validate می‌کند. + +#### مرحله 2: پیدا کردن یا ساخت location + +بر اساس `lat/lon`: + +- اگر location وجود نداشته باشد ساخته می‌شود +- اگر وجود داشته باشد از همان رکورد استفاده می‌شود + +#### مرحله 3: آپدیت ساختار اولیه بلوک‌ها + +اگر `block_count` فرق کرده باشد: + +- `block_layout` دوباره با `set_input_block_count()` ساخته می‌شود + +#### مرحله 4: انتخاب boundary برای subdivision + +اولویت: + +1. `farm_boundary` ارسالی در request +2. اگر نبود، `location.farm_boundary` ذخیره‌شده + +#### مرحله 5: اجرای subdivision فقط در صورت نیاز + +تابع: + +- `create_or_get_block_subdivision()` + +اگر رکورد `(location, block_code)` از قبل وجود داشته باشد: + +- هیچ پردازش جدیدی اجرا نمی‌شود +- همان رکورد قبلی برگردانده می‌شود + +اگر وجود نداشته باشد: + +- grid ساخته می‌شود +- KMeans اجرا می‌شود +- elbow پیدا می‌شود +- centroidها ساخته می‌شوند +- نمودار elbow ساخته می‌شود +- همه چیز در `BlockSubdivision` ذخیره می‌شود +- `block_layout` با `sub_blocks` sync می‌شود + +#### مرحله 6: response + +خروجی شامل این‌هاست: + +- اطلاعات `SoilLocation` +- `farm_boundary` +- `block_layout` +- `block_subdivisions` +- `depths` + +فیلد `source` در response: + +- `created` اگر location یا subdivision جدید ساخته شده باشد +- `database` اگر قبلاً وجود داشته باشد + +--- + +## 9) جریان کامل `GET /api/soil-data/` + +این endpoint الان فقط برای read استفاده می‌شود. + +### ورودی + +- `lat` +- `lon` +- `block_code` اختیاری + +### رفتار + +- location را از دیتابیس پیدا می‌کند +- subdivisionهای ذخیره‌شده را می‌خواند +- هیچ الگوریتمی را اجرا نمی‌کند +- هیچ `KMeans` یا پردازش هندسی انجام نمی‌دهد + +### پاسخ + +داده ذخیره‌شده را با `source = database` برمی‌گرداند. + +اگر location پیدا نشود: + +- `404` + +--- + +## 10) نقش `serializers.py` + +### `SoilDataRequestSerializer` + +ورودی endpoint اصلی را مدیریت می‌کند: + +- `lat` +- `lon` +- `block_count` +- `block_code` +- `farm_boundary` + +### `SoilLocationResponseSerializer` + +خروجی location را برمی‌گرداند: + +- `id` +- `lat` +- `lon` +- `input_block_count` +- `farm_boundary` +- `block_layout` +- `block_subdivisions` +- `depths` + +### `BlockSubdivisionSerializer` + +خروجی subdivision را برمی‌گرداند: + +- `block_code` +- `chunk_size_sqm` +- `grid_points` +- `centroid_points` +- `grid_point_count` +- `centroid_count` +- `elbow_plot` +- `status` +- `metadata` +- `created_at` +- `updated_at` + +--- + +## 11) نقش `block_layout` در کنار `BlockSubdivision` + +در معماری فعلی دو سطح ذخیره‌سازی داریم: + +### 11.1) `BlockSubdivision` + +منبع اصلی و canonical برای subdivision + +### 11.2) `block_layout` + +خلاصه‌ای از نتیجه subdivision برای مصرف سریع‌تر در response و ساختار کلی location + +یعنی: + +- داده دقیق در `BlockSubdivision` است +- خلاصه آن در `block_layout.blocks[].sub_blocks` قرار می‌گیرد + +--- + +## 12) وضعیت فعلی بخش‌های قدیمی‌تر اپ + +## 12.1) `tasks.py` + +این فایل هنوز وجود دارد و برای fetch داده خاک به صورت قدیمی استفاده می‌شود، اما در مسیر subdivision فعلی نقشی ندارد. + +## 12.2) `soil_adapters.py` + +این فایل adapterهای داده خاک را نگه می‌دارد و فعلاً برای subdivision استفاده نمی‌شود. + +## 12.3) `remote_sensing.py` + +منطق سنجش‌ازدور را نگه می‌دارد و هنوز مستقیماً به subdivision وصل نشده است. + +## 12.4) `ndvi.py` + +برای endpoint مربوط به NDVI استفاده می‌شود و فعلاً از centroidهای subdivision استفاده نمی‌کند. + +--- + +## 13) وابستگی‌های جدید + +برای عملکرد فعلی subdivision این dependencyها لازم هستند: + +- `scikit-learn` +- `matplotlib` +- `Pillow` +- `numpy` + +### دلیل هرکدام + +- `scikit-learn`: اجرای `KMeans` +- `matplotlib`: رسم elbow plot +- `Pillow`: پشتیبانی از `ImageField` +- `numpy`: وابستگی پایه `scikit-learn` + +--- + +## 14) migrationهای مهم مرتبط با ساختار فعلی + +- `0008_soillocation_block_layout.py` + - اضافه شدن `input_block_count` + - اضافه شدن `block_layout` + +- `0009_blocksubdivision.py` + - اضافه شدن مدل `BlockSubdivision` + +- `0010_blocksubdivision_elbow_plot.py` + - اضافه شدن فیلد `elbow_plot` + +--- + +## 15) محدودیت‌های فعلی + +چند محدودیت مهم در پیاده‌سازی فعلی وجود دارد: + +- subdivision فعلاً بر اساس هندسه و خوشه‌بندی نقاط انجام می‌شود، نه بر اساس داده واقعی خاک یا NDVI +- برای هر `block_code` فرض می‌شود یک مرز مستقل از بیرون داده می‌شود +- هنوز برای هر `sub_block` رکورد location مستقل ساخته نمی‌شود +- هنوز داده خاک، هوا و NDVI برای centroidهای جدید به صورت جداگانه fetch نمی‌شود +- elbow detection فعلی heuristic-based است و هنوز نسخه پیشرفته‌تر آماری ندارد + +--- + +## 16) تست‌های مرتبط + +### `location_data/test_block_subdivision.py` + +این تست‌ها بررسی می‌کنند: + +- elbow detection کار می‌کند +- payload subdivision ساخته می‌شود +- grid points و centroid points خروجی دارند + +### `location_data/test_soil_api.py` + +این تست‌ها بررسی می‌کنند: + +- `POST` subdivision جدید می‌سازد +- `GET` فقط داده ذخیره‌شده را برمی‌گرداند +- الگوریتم در `GET` دوباره اجرا نمی‌شود + +--- + +## 17) جمع‌بندی معماری فعلی + +در وضعیت فعلی، `location_data` این معماری را دارد: + +### لایه 1: Location پایه + +- `SoilLocation` +- `farm_boundary` +- `block_layout` + +### لایه 2: Subdivision هوشمند + +- `BlockSubdivision` +- grid generation +- KMeans +- elbow detection +- centroid generation +- elbow plot generation + +### لایه 3: داده‌های مکمل + +- `SoilDepthData` +- `NdviObservation` +- بخش‌های legacy مثل `tasks.py` + +در نتیجه، اپ الان می‌تواند: + +- یک بلوک با مرز مشخص بگیرد +- آن را به نقاط شبکه‌ای خرد کند +- تعداد بهینه بخش‌ها را با KMeans + Elbow پیدا کند +- centroidهای نهایی را ذخیره کند +- نمودار elbow را ذخیره کند +- و در درخواست‌های بعدی فقط همان نتیجه ذخیره‌شده را بدون پردازش مجدد برگرداند + +--- + +## 18) پیشنهاد برای مراحل بعدی + +اگر در مرحله بعد بخواهی این ساختار را توسعه بدهی، منطقی‌ترین قدم‌ها این‌ها هستند: + +1. ساخت endpoint مستقل برای subdivision هر block +2. اتصال هر centroid به fetch داده خاک و هوا +3. ساخت رکورد مستقل برای هر `sub_block` +4. استفاده از NDVI یا داده سنسور برای تعیین `K` یا وزن‌دهی خوشه‌ها +5. نمایش مستقیم `elbow_plot` با URL کامل media + diff --git a/Modules/Ai/docs/location_data_full_architecture.md b/Modules/Ai/docs/location_data_full_architecture.md new file mode 100644 index 0000000..2832eaa --- /dev/null +++ b/Modules/Ai/docs/location_data_full_architecture.md @@ -0,0 +1,972 @@ +# مستند کامل اپ `location_data` + +این سند، وضعیت فعلی اپ `location_data` را به صورت کامل توضیح می‌دهد: + +- مدل‌های داده +- منطق business +- جریان ساخت location و block +- subdivision و خوشه‌بندی +- تولید analysis grid +- سنجش‌ازدور با openEO +- تسک‌های Celery +- APIهای فعلی +- ساختار responseها +- محدودیت‌ها و فرضیات فعلی + +این فایل بر اساس کد فعلی پروژه نوشته شده است و هدفش این است که یک مرجع فنی برای توسعه‌دهنده‌های بعدی باشد. + +--- + +## 1) هدف اپ `location_data` + +اپ `location_data` در وضعیت فعلی چند نقش اصلی دارد: + +1. نگه‌داری موقعیت جغرافیایی زمین با `lat/lon` +2. نگه‌داری مرز زمین یا مرز blockها +3. ساخت ساختار blockهای مزرعه +4. اجرای subdivision برای blockها +5. تولید grid analysis با ابعاد 30x30 متر +6. نگه‌داری نتایج سنجش‌ازدور روی هر grid cell +7. نگه‌داری داده‌های خاک و NDVI سنتی +8. فراهم کردن API برای: + - location data + - subdivision + - remote sensing trigger/result + - NDVI health + +به صورت خلاصه، `location_data` الان فقط یک جدول مختصات نیست؛ بلکه هاب مکانی پروژه است. + +--- + +## 2) مفاهیم اصلی دامنه + +### 2.1) SoilLocation + +`SoilLocation` نماینده یک location اصلی برای یک مزرعه یا مرکز زمین است. + +این مدل: +- مختصات `latitude` و `longitude` را نگه می‌دارد +- `farm_boundary` را ذخیره می‌کند +- تعداد blockهای اولیه را نگه می‌دارد +- `block_layout` را نگه می‌دارد +- مبنای ارتباط با: + - `SoilDepthData` + - `BlockSubdivision` + - `AnalysisGridCell` + - `RemoteSensingRun` + - `NdviObservation` + +--- + +### 2.2) BlockSubdivision + +`BlockSubdivision` نتیجه خردسازی یک block است. + +این مدل نگه می‌دارد: +- block code +- مرز همان block +- chunk size برای subdivision +- grid points اولیه +- centroid points نهایی +- elbow plot +- metadata الگوریتم + +این مدل برای مرحله‌ای است که یک block را به بخش‌های کوچک‌تر تقسیم می‌کنیم. + +--- + +### 2.3) AnalysisGridCell + +`AnalysisGridCell` سلول‌های 30x30 متری تحلیل سنجش‌ازدور را نگه می‌دارد. + +هر cell: +- به یک `SoilLocation` وصل است +- در صورت نیاز به یک `BlockSubdivision` وصل است +- یک `cell_code` یکتا دارد +- geometry خودش را به صورت Polygon نگه می‌دارد +- centroid خودش را نگه می‌دارد + +این مدل واحد اصلی تحلیل remote sensing است. + +--- + +### 2.4) AnalysisGridObservation + +`AnalysisGridObservation` داده زمانی هر سلول را نگه می‌دارد. + +برای هر cell و بازه زمانی: +- `ndvi` +- `ndwi` +- `lst_c` +- `soil_vv` +- `soil_vv_db` +- `dem_m` +- `slope_deg` + +ذخیره می‌شود. + +این مدل cache دیتابیسی اصلی برای نتایج openEO است. + +--- + +### 2.5) RemoteSensingRun + +`RemoteSensingRun` وضعیت یک اجرای async سنجش‌ازدور را نگه می‌دارد. + +این مدل: +- به `SoilLocation` وصل است +- optionally به `BlockSubdivision` وصل است +- `block_code` و بازه زمانی را نگه می‌دارد +- status execution را نگه می‌دارد +- metadata مربوط به task/backend/result summary را نگه می‌دارد + +این مدل برای tracking jobها در Celery استفاده می‌شود. + +--- + +### 2.6) SoilDepthData + +این مدل داده‌های خاک را در عمق‌های مختلف نگه می‌دارد: +- `0-5cm` +- `5-15cm` +- `15-30cm` + +--- + +### 2.7) NdviObservation + +این مدل نگه‌دارنده NDVI سنتی است که جدا از workflow جدید openEO هم هنوز وجود دارد. + +--- + +## 3) ساختار فایل‌های مهم اپ + +```text +location_data/ +├── admin.py +├── apps.py +├── block_subdivision.py +├── grid_analysis.py +├── models.py +├── ndvi.py +├── openeo_service.py +├── remote_sensing.py +├── serializers.py +├── soil_adapters.py +├── tasks.py +├── urls.py +├── views.py +├── migrations/ +└── tests... +``` + +### نقش فایل‌ها + +- `models.py`: مدل‌های اصلی +- `serializers.py`: serializerهای API +- `views.py`: endpointهای DRF +- `urls.py`: routeها +- `tasks.py`: تسک‌های Celery +- `block_subdivision.py`: subdivision و elbow/kmeans +- `grid_analysis.py`: ساخت analysis grid cells +- `openeo_service.py`: لایه سرویس openEO +- `remote_sensing.py`: منطق قدیمی‌تر سنجش‌ازدور/NDVI ساده +- `soil_adapters.py`: adapterهای داده خاک + +--- + +## 4) تنظیمات مهم + +### `SUBDIVISION_CHUNK_SQM` + +در `config/settings.py`: + +```python +SUBDIVISION_CHUNK_SQM = int(os.environ.get("SUBDIVISION_CHUNK_SQM", "900")) +``` + +مقدار پیش‌فرض فعلی: +- `900` + +معنا: +- grid analysis با سلول‌های `30m x 30m` + +چون: + +```text +step_m = sqrt(900) = 30m +``` + +--- + +## 5) مدل‌های دیتابیس و منطق آن‌ها + +## 5.1) SoilLocation + +فیلدهای مهم: + +- `latitude` +- `longitude` +- `task_id` +- `farm_boundary` +- `input_block_count` +- `block_layout` +- `created_at` +- `updated_at` + +### قید مهم + +- `latitude + longitude` یکتا هستند + +### block_layout + +`block_layout` JSON summary کلی blockها را نگه می‌دارد. + +نمونه: + +```json +{ + "input_block_count": 1, + "default_full_farm": true, + "algorithm_status": "completed", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "needs_subdivision": true, + "sub_blocks": [ + { + "sub_block_code": "sub-block-1", + "centroid_lat": 35.689123, + "centroid_lon": 51.389456 + } + ], + "subdivision_summary": { + "chunk_size_sqm": 900, + "grid_point_count": 12, + "centroid_count": 3, + "optimal_k": 3 + }, + "analysis_grid_summary": { + "chunk_size_sqm": 900, + "cell_count": 18 + } + } + ] +} +``` + +`block_layout` canonical source نیست؛ بیشتر یک summary سریع برای API است. + +--- + +## 5.2) BlockSubdivision + +فیلدهای مهم: + +- `soil_location` +- `block_code` +- `source_boundary` +- `chunk_size_sqm` +- `grid_points` +- `centroid_points` +- `grid_point_count` +- `centroid_count` +- `elbow_plot` +- `status` +- `metadata` + +### نقش + +برای هر `block_code` در هر location، نتیجه subdivision در این مدل ذخیره می‌شود. + +### metadata + +شامل مواردی مثل: +- `estimated_area_sqm` +- `optimal_k` +- `inertia_curve` +- `analysis_grid` + +--- + +## 5.3) RemoteSensingRun + +فیلدهای مهم: + +- `soil_location` +- `block_subdivision` +- `block_code` +- `provider` +- `chunk_size_sqm` +- `temporal_start` +- `temporal_end` +- `status` +- `metadata` +- `error_message` +- `started_at` +- `finished_at` + +### statusها + +- `pending` +- `running` +- `success` +- `failure` + +### نقش + +این جدول وضعیت اجرای async را نگه می‌دارد. + +--- + +## 5.4) AnalysisGridCell + +فیلدهای مهم: + +- `soil_location` +- `block_subdivision` +- `block_code` +- `cell_code` +- `chunk_size_sqm` +- `geometry` +- `centroid_lat` +- `centroid_lon` + +### نقش + +واحد spatial اصلی برای تحلیل remote sensing است. + +### idempotency + +سطح سرویس با این شرط enforce می‌شود: +- اگر برای یک `SoilLocation + block_code + chunk_size_sqm` cellها قبلاً ساخته شده باشند، دوباره ساخته نمی‌شوند. + +### geometry + +به صورت GeoJSON-like polygon ذخیره می‌شود. + +--- + +## 5.5) AnalysisGridObservation + +فیلدهای مهم: + +- `cell` +- `run` +- `temporal_start` +- `temporal_end` +- `ndvi` +- `ndwi` +- `lst_c` +- `soil_vv` +- `soil_vv_db` +- `dem_m` +- `slope_deg` +- `metadata` + +### uniqueness + +برای جلوگیری از duplicate: +- روی `cell + temporal_start + temporal_end` constraint داریم. + +این باعث می‌شود cache دیتابیسی پایدار باشد. + +--- + +## 5.6) SoilDepthData + +این مدل داده‌های خاک را در عمق‌های مختلف نگه می‌دارد. + +هنوز به صورت مستقیم برای هر analysis grid cell جداگانه استفاده نشده است. + +--- + +## 5.7) NdviObservation + +این مدل legacy / parallel NDVI store است. + +workflow جدید openEO جایگزین آن نشده، بلکه کنار آن وجود دارد. + +--- + +## 6) منطق subdivision در `block_subdivision.py` + +این فایل مسئول خردسازی blockها است. + +### کارهایی که انجام می‌دهد + +- استخراج polygon از boundary +- تبدیل مختصات جغرافیایی به مختصات محلی متری +- تولید grid points اولیه +- اجرای KMeans برای `K=1..10` +- محاسبه SSE/Inertia +- پیدا کردن elbow point +- انتخاب centroidها +- رسم elbow plot با matplotlib +- ذخیره plot در `ImageField` +- sync کردن نتیجه با `block_layout` + +### input + +ممکن است boundary به شکل‌های زیر برسد: +- GeoJSON Polygon +- corners +- list مستقیم از points + +### خروجی + +- centroidهای نهایی block +- metadata الگوریتم +- elbow plot + +--- + +## 7) منطق ساخت analysis grid در `grid_analysis.py` + +این فایل مسئول تولید سلول‌های 30x30 متری برای تحلیل remote sensing است. + +### تابع اصلی + +- `create_or_get_analysis_grid_cells(...)` + +### ورودی‌ها + +- `location` +- optional `boundary` +- optional `block_code` +- optional `block_subdivision` +- optional `chunk_size_sqm` + +### رفتار + +1. chunk size را تعیین می‌کند +2. boundary را resolve می‌کند +3. polygon را extract می‌کند +4. اگر قبلاً برای همان `location + block_code + chunk_size` cell ساخته شده باشد، خروجی existing برمی‌گرداند +5. اگر نه، grid cellها ساخته می‌شوند و `AnalysisGridCell` ذخیره می‌شود + +### نحوه ساخت شبکه + +- polygon به دستگاه محلی متری تبدیل می‌شود +- `step_m = sqrt(chunk_size_sqm)` محاسبه می‌شود +- یک grid مستطیلی روی bounding box ساخته می‌شود +- هر cell که با polygon intersect داشته باشد نگه داشته می‌شود + +### cell_code + +فرمت فعلی deterministic است: + +```text +loc-{location_id}__block-{block_code}__chunk-{chunk_size_sqm}__rXXXXcYYYY +``` + +### metadata summary + +پس از ساخت grid: +- روی `BlockSubdivision.metadata["analysis_grid"]` +- و روی `SoilLocation.block_layout` + +summary ذخیره می‌شود. + +--- + +## 8) منطق openEO در `openeo_service.py` + +این فایل لایه service اصلی برای تحلیل openEO است. + +### backend + +```text +https://openeofed.dataspace.copernicus.eu +``` + +### هدف + +گرفتن batch metricها برای مجموعه‌ای از `AnalysisGridCell`ها. + +### جریان کلی + +1. اتصال و auth به openEO +2. ساخت `FeatureCollection` از cellها +3. ساخت `spatial_extent` +4. اجرای یک job per metric روی همه cellها +5. parse کردن نتیجه aggregate_spatial +6. merge کردن metricها روی map keyed by `cell_code` + +### metricهای فعلی + +- `ndvi` از `SENTINEL2_L2A` +- `ndwi` از `SENTINEL2_L2A` +- `lst_c` از `SENTINEL3_SLSTR_L2_LST` +- `soil_vv` از `SENTINEL1_GRD` +- `soil_vv_db` در Python از `soil_vv` +- `dem_m` از `COPERNICUS_30` +- `slope_deg` از DEM اگر backend پشتیبانی کند + +### cloud mask Sentinel-2 + +کلاس‌های معتبر SCL: +- `4` +- `5` +- `6` + +نکته مهم: +- از `isin()` استفاده نمی‌شود +- فقط logical comparison استفاده می‌شود + +### aggregate_spatial + +فقط از: + +```python +aggregate_spatial(geometries=feature_collection, reducer="mean") +``` + +استفاده می‌شود. + +### slope support + +اگر backend `slope()` را پشتیبانی نکند: +- `slope_deg = null` +- و metadata می‌گوید `slope_supported=False` + +### normalized output + +خروجی نهایی به این شکل است: + +```python +{ + "results": { + "cell-1": { + "ndvi": ..., + "ndwi": ..., + "lst_c": ..., + "soil_vv": ..., + "soil_vv_db": ..., + "dem_m": ..., + "slope_deg": ..., + } + }, + "metadata": { + "backend": "openeo", + "collections_used": [...], + "slope_supported": True, + "job_refs": {}, + "failed_metrics": [] + } +} +``` + +--- + +## 9) Celery workflow در `tasks.py` + +### تسک قدیمی + +- `fetch_soil_data_task` + +برای خاک legacy است. + +### workflow جدید remote sensing + +تابع/تسک‌های اصلی: + +- `run_remote_sensing_analysis(...)` +- `run_remote_sensing_analysis_task.delay(...)` + +### ورودی task + +- `soil_location_id` +- optional `block_code` +- `temporal_start` +- `temporal_end` +- optional `force_refresh` +- optional `run_id` + +### رفتار task + +1. `SoilLocation` را پیدا می‌کند +2. `BlockSubdivision` را اگر لازم باشد resolve می‌کند +3. `RemoteSensingRun` را create/update می‌کند +4. `AnalysisGridCell`ها را ensure می‌کند +5. اگر observation برای همان range قبلاً باشد و `force_refresh=False`، دوباره process نمی‌کند +6. در غیر این صورت، `compute_remote_sensing_metrics()` را صدا می‌زند +7. `AnalysisGridObservation`ها را upsert می‌کند +8. status run را success/failure می‌کند + +### idempotency + +اگر observation قبلاً برای همان: +- cell +- temporal_start +- temporal_end + +وجود داشته باشد، duplicate ساخته نمی‌شود. + +### retry behavior + +task روی خطاهای transient مثل: +- `OpenEOExecutionError` +- `OpenEOServiceError` +- request-level failures + +retry می‌کند. + +روی auth failure retry نمی‌کند. + +--- + +## 10) APIهای فعلی `location_data` + +## 10.1) `GET /api/soil-data/` + +کاربرد: +- فقط اطلاعات ذخیره‌شده location را برمی‌گرداند +- subdivision را rerun نمی‌کند + +ورودی: +- `lat` +- `lon` +- optional `block_code` + +خروجی: +- location data +- block layout +- block subdivisions +- depths + +--- + +## 10.2) `POST /api/soil-data/` + +کاربرد: +- `SoilLocation` را create/get می‌کند +- در صورت نیاز `BlockSubdivision` می‌سازد + +ورودی: +- `lat` +- `lon` +- `block_count` +- `block_code` +- `farm_boundary` + +خروجی: +- location کامل +- `source` = `created` یا `database` + +--- + +## 10.3) `GET /api/soil-data/tasks//status/` + +کاربرد: +- status task قدیمی fetch خاک + +--- + +## 10.4) `POST /api/soil-data/ndvi-health/` + +کاربرد: +- NDVI health مستقل برای farm + +--- + +## 10.5) `POST /api/soil-data/remote-sensing/` + +کاربرد: +- remote sensing analysis را queue می‌کند +- heavy work را sync اجرا نمی‌کند + +ورودی: +- `lat` +- `lon` +- optional `block_code` +- `start_date` +- `end_date` +- optional `force_refresh` + +رفتار: +1. location را پیدا می‌کند +2. run می‌سازد +3. Celery task را enqueue می‌کند +4. `202 Accepted` برمی‌گرداند + +خروجی شامل: +- `status=processing` +- `source=processing` +- `location` +- `block_code` +- `chunk_size_sqm` +- `temporal_extent` +- `summary` خالی +- `cells=[]` +- `run` +- `task_id` + +--- + +## 10.6) `GET /api/soil-data/remote-sensing/` + +کاربرد: +- فقط cache دیتابیسی remote sensing را می‌خواند +- هیچ openEO یا subdivision sync اجرا نمی‌کند + +ورودی: +- `lat` +- `lon` +- optional `block_code` +- `start_date` +- `end_date` + +خروجی حالت‌ها: + +### حالت 1: result موجود است +- `status=success` +- `source=database` +- summary metrics +- cells list +- run info + +### حالت 2: result هنوز نیست ولی job در حال اجراست +- `status=processing` +- `source=processing` +- summary خالی +- cells empty +- run info + +### حالت 3: location نیست +- `404` + +--- + +## 11) serializerهای مهم + +### `SoilDataRequestSerializer` +برای endpoint اصلی location. + +### `SoilLocationResponseSerializer` +برای بازگشت location + blocks + depths. + +### `BlockSubdivisionSerializer` +برای بازگشت subdivision data. + +### `RemoteSensingTriggerSerializer` +برای trigger API remote sensing. + +### `RemoteSensingCellObservationSerializer` +برای بازگشت per-cell remote sensing metrics. + +### `RemoteSensingSummarySerializer` +برای بازگشت summary statisticها. + +### `RemoteSensingRunSerializer` +برای بازگشت status run. + +### `RemoteSensingResponseSerializer` +برای payload کامل remote sensing GET. + +--- + +## 12) منطق summary statistics در remote sensing GET + +در response مربوط به `GET /remote-sensing/` این فیلدها برمی‌گردند: + +- `cell_count` +- `ndvi_mean` +- `ndwi_mean` +- `lst_c_mean` +- `soil_vv_db_mean` +- `dem_m_mean` +- `slope_deg_mean` + +این‌ها از روی observationهای موجود در DB محاسبه می‌شوند، نه از openEO live. + +--- + +## 13) admin + +در `admin.py` الان موارد زیر رجیستر شده‌اند: + +- `SoilLocation` +- `SoilDepthData` +- `BlockSubdivision` +- `RemoteSensingRun` +- `AnalysisGridCell` +- `AnalysisGridObservation` + +این باعث می‌شود debugging و inspection از طریق admin ممکن باشد. + +--- + +## 14) تست‌های فعلی + +### `test_soil_api.py` +- ساخت location +- ساخت subdivision +- رفتار GET/POST location + +### `test_block_subdivision.py` +- elbow detection +- payload subdivision + +### `test_grid_analysis.py` +- ساخت analysis grid 30x30 +- idempotency grid cells +- استفاده از boundary location + +### `test_openeo_service.py` +- parse نتیجه aggregate_spatial +- merge metricها +- conversion به dB + +### `test_remote_sensing_api.py` +- queue شدن remote sensing task +- processing response +- cache read response +- not found behavior + +### `test_ndvi_health_api.py` +- NDVI health API + +--- + +## 15) وابستگی‌های مهم + +در `requirements.txt` dependencyهای مهم این بخش‌ها شامل این‌ها هستند: + +- `scikit-learn` +- `matplotlib` +- `Pillow` +- `numpy` +- `openeo` + +### نقش آن‌ها + +- `scikit-learn`: KMeans +- `matplotlib`: elbow plot +- `Pillow`: ImageField support +- `numpy`: وابستگی عددی +- `openeo`: ارتباط با backend سنجش‌ازدور + +--- + +## 16) migrationهای مهم + +- `0008_soillocation_block_layout.py` +- `0009_blocksubdivision.py` +- `0010_blocksubdivision_elbow_plot.py` +- `0011_remote_sensing_models.py` + +این migrationها ساختار فعلی location/subdivision/remote sensing را ساخته‌اند. + +--- + +## 17) محدودیت‌ها و فرضیات فعلی + +### محدودیت‌های فعلی + +1. `block_layout` canonical source نیست و summary است. +2. subdivision و analysis grid دو لایه جدا هستند. +3. slope ممکن است روی backend همیشه پشتیبانی نشود. +4. API GET remote-sensing فقط cache می‌خواند. +5. هنوز endpoint مجزای status run نداریم. +6. grid generation از projection محلی استفاده می‌کند، نه GIS stack سنگین. +7. openEO calls فعلاً برای batch metric processing طراحی شده‌اند، نه orchestration پیچیده job lifecycle. + +### فرضیات + +1. مزرعه‌ها آن‌قدر کوچک هستند که local projected approximation مناسب باشد. +2. `SUBDIVISION_CHUNK_SQM=900` برای workflow فعلی درست است. +3. `cell_code` deterministic بودن برای idempotency کافی است. +4. `AnalysisGridObservation` cache اصلی remote sensing است. + +--- + +## 18) جریان کامل داده از ابتدا تا نتیجه remote sensing + +### مرحله 1: ایجاد location + +کاربر `POST /api/soil-data/` را صدا می‌زند. + +نتیجه: +- `SoilLocation` ساخته می‌شود +- `farm_boundary` ذخیره می‌شود +- block layout ساخته می‌شود +- در صورت نیاز `BlockSubdivision` ساخته می‌شود + +### مرحله 2: تولید analysis grid + +وقتی task remote sensing اجرا می‌شود: +- اگر cellها قبلاً نباشند، `AnalysisGridCell`ها ساخته می‌شوند + +### مرحله 3: اجرای openEO + +Celery task: +- FeatureCollection از cellها می‌سازد +- metricها را batch اجرا می‌کند +- نتیجه را parse می‌کند + +### مرحله 4: ذخیره observation + +برای هر cell: +- یک `AnalysisGridObservation` برای بازه زمانی موردنظر ذخیره/آپدیت می‌شود + +### مرحله 5: بازگشت نتیجه از API + +کاربر `GET /api/soil-data/remote-sensing/` را صدا می‌زند. + +سیستم: +- فقط DB را می‌خواند +- summary می‌سازد +- cells را برمی‌گرداند + +--- + +## 19) پیشنهاد توسعه بعدی + +برای ادامه توسعه، این‌ها منطقی‌ترین قدم‌ها هستند: + +1. ساخت endpoint مستقل status برای `RemoteSensingRun` +2. اضافه کردن pagination برای cell responseها +3. اضافه کردن job reference واقعی openEO در metadata +4. پشتیبانی از چند resolution دیگر غیر از 30x30 +5. ساخت serializer/model جدا برای summaryهای precomputed +6. اضافه کردن نمودارها یا aggregationهای block-level +7. اتصال remote sensing resultها به recommendation engine + +--- + +## 20) جمع‌بندی نهایی + +اپ `location_data` الان یک سیستم چندلایه است: + +### لایه مکانی پایه +- `SoilLocation` +- `farm_boundary` +- `block_layout` + +### لایه subdivision +- `BlockSubdivision` +- KMeans +- elbow plot + +### لایه grid analysis +- `AnalysisGridCell` + +### لایه observation +- `AnalysisGridObservation` +- `RemoteSensingRun` + +### لایه سرویس +- `block_subdivision.py` +- `grid_analysis.py` +- `openeo_service.py` +- `tasks.py` + +### لایه API +- `SoilDataView` +- `RemoteSensingAnalysisView` +- `NdviHealthView` + +در نتیجه، `location_data` الان از یک app ساده location عبور کرده و به یک زیرسیستم کامل spatial + remote sensing تبدیل شده است. diff --git a/Modules/Ai/docs/pcse_api_list.md b/Modules/Ai/docs/pcse_api_list.md new file mode 100644 index 0000000..c5c02a3 --- /dev/null +++ b/Modules/Ai/docs/pcse_api_list.md @@ -0,0 +1,290 @@ +# لیست APIهایی که با استفاده از PCSE تحلیل انجام می‌دهند + +این فایل APIهایی را فهرست می‌کند که در این پروژه یا مستقیماً از `PCSE` استفاده می‌کنند، یا بخشی از تحلیلشان به خروجی‌های شبیه‌سازی `PCSE` وابسته است. +همچنین مشخص می‌کند: + +- از چه مدل `PCSE` استفاده می‌شود +- ورودی‌های لازم `PCSE` در این پروژه از کجا تأمین می‌شوند +- برنامه آبیاری، برنامه کودهی و داده آب‌وهوا چطور به مدل تزریق می‌شوند + +## مدل PCSE مورد استفاده در پروژه + +مدل پیش‌فرضی که در سرویس شبیه‌سازی استفاده می‌شود این است: + +- `Wofost81_NWLP_CWB_CNB` + +توضیح کوتاه: + +- `Wofost81`: نسخه 8.1 از خانواده مدل‌های WOFOST +- `NWLP`: شبیه‌سازی با محدودیت نیتروژن +- `CWB`: water balance +- `CNB`: carbon/nitrogen balance + +بنابراین APIهایی که واقعاً از موتور اصلی شبیه‌سازی استفاده می‌کنند، عملاً روی این مدل اجرا می‌شوند مگر اینکه بعداً در کد override شده باشد. + +## PCSE در این پروژه چه ورودی‌هایی می‌خواهد؟ + +در این پروژه لایه شبیه‌سازی برای اجرای `PCSE` این ورودی‌ها را می‌سازد: + +### 1. `weather` +رکوردهای آب‌وهوا با فیلدهای زیر: + +- `DAY` +- `LAT` +- `LON` +- `ELEV` +- `IRRAD` +- `TMIN` +- `TMAX` +- `VAP` +- `WIND` +- `RAIN` +- `E0` +- `ES0` +- `ET0` + +### 2. `soil` +پارامترهای خاک، از جمله: + +- `SMFCF` +- `SMW` +- `SM0` +- `RDMSOL` +- `CRAIRC` +- `SOPE` +- `KSUB` + +و در این پروژه بعضی شاخص‌های کمکی هم کنار آن نگهداری می‌شوند، مثل: + +- `nitrogen` +- `phosphorus` +- `potassium` +- `soil_ph` +- `electrical_conductivity` + +### 3. `site_parameters` +پارامترهای سایت/شرایط اولیه، از جمله: + +- `WAV` +- `SMLIM` +- `IFUNRN` +- `NOTINF` +- `SSI` +- `SSMAX` +- `NAVAILI` + +### 4. `crop_parameters` +پارامترهای محصول. این‌ها یا از پروفایل شبیه‌سازی گیاه می‌آیند، یا اگر موجود نباشند به‌صورت default ساخته می‌شوند. مهم‌ترین defaultها: + +- `crop_name` +- `TSUM1` +- `TSUM2` +- `YIELD_SCALE` +- `MAX_LAI` +- `MAX_BIOMASS` + +### 5. `agromanagement` +تقویم و رویدادهای زراعی، شامل: + +- `CropCalendar` +- `TimedEvents` +- `StateEvents` + +همین بخش جایی است که برنامه آبیاری و برنامه کودهی به شبیه‌سازی تزریق می‌شود. + +## ورودی‌ها از کجا می‌آیند؟ + +### آب‌وهوا +منبع اصلی: + +- جدول `WeatherForecast` + +مسیر تولید: + +- با `farm_uuid` مزرعه پیدا می‌شود +- از `center_location` مزرعه، forecastها خوانده می‌شوند +- معمولاً تا `14` روز آینده برداشته می‌شوند +- داده‌های `precipitation` و `et0` که در دیتابیس به `mm/day` هستند، برای `PCSE` به `cm/day` تبدیل می‌شوند + +فیلدهایی که از forecast استفاده می‌شوند: + +- `forecast_date` → `DAY` +- `temperature_min` → `TMIN` +- `temperature_max` یا `temperature_mean` → `TMAX` +- `humidity_mean` → `VAP` +- `wind_speed_max` → `WIND` +- `precipitation` → `RAIN` +- `et0` → `E0`, `ES0`, `ET0` + +اگر کاربر خودش `weather` را مستقیم بدهد، همان ورودی مستقیم استفاده می‌شود. + +### خاک و وضعیت سایت +منبع اصلی: + +- جدول `SensorData` +- رابطه `center_location.depths` +- بخشی از `sensor_payload` + +نحوه ساخت: + +- از لایه سطحی خاک (`top_depth`) پارامترهایی مثل `wv0033`, `wv1500`, `porosity` خوانده می‌شوند +- از روی آن‌ها `SMFCF`, `SMW`, `SM0` ساخته می‌شود +- از `sensor_payload`، شاخص‌هایی مثل `soil_moisture`, `nitrogen`, `phosphorus`, `potassium`, `soil_ph`, `electrical_conductivity` استخراج می‌شود +- سپس از این‌ها `soil` و `site_parameters` نهایی ساخته می‌شود + +### پارامترهای محصول +منبع اصلی: + +- مدل `Plant` + +اولویت تأمین: + +1. `simulation profile` داخل یکی از این profileها: + - `growth_profile.simulation` + - `irrigation_profile.simulation` + - `health_profile.simulation` +2. اگر profile آماده وجود نداشته باشد، پارامترهای پیش‌فرض از روی اطلاعات رشد گیاه ساخته می‌شوند + +### تقویم زراعی `agromanagement` +منبع اصلی: + +- اگر در `simulation profile` گیاه موجود باشد، از همان استفاده می‌شود +- وگرنه به‌صورت پیش‌فرض از بازه زمانی آب‌وهوای موجود ساخته می‌شود + +ساختار پیش‌فرض: + +- `crop_start_date` از اولین روز forecast +- `crop_end_date` از آخرین روز forecast یا کمی بعد از آن +- `TimedEvents` و `StateEvents` به‌صورت اولیه خالی هستند + +## 1) APIهای مستقیم و قطعی مبتنی بر PCSE + +### 1. `POST /api/crop-simulation/growth/` +- کاربرد: اجرای شبیه‌سازی رشد گیاه. +- نقش PCSE: هسته اصلی این API اجرای مدل شبیه‌سازی `PCSE/WOFOST` است. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: + - آب‌وهوا: از `WeatherForecast` یا ورودی مستقیم `weather` + - خاک: از `SensorData` و `center_location.depths` + - crop parameters: از `Plant` و `simulation profile` یا default + - agromanagement: از `simulation profile` یا default +- نوع استفاده: مستقیم. + +### 2. `GET /api/crop-simulation/growth//status/` +- کاربرد: دریافت وضعیت و نتیجه شبیه‌سازی رشد. +- نقش PCSE: نتیجه‌ای که برمی‌گرداند خروجی همان شبیه‌سازی مبتنی بر `PCSE` است. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- نوع استفاده: مستقیم. + +### 3. `POST /api/crop-simulation/current-farm-chart/` +- کاربرد: تولید chart وضعیت فعلی مزرعه. +- نقش PCSE: داده‌های chart مثل `LAI`، `TAGP`، `TWSO`، `SM` و `daily_output` از شبیه‌سازی `PCSE` ساخته می‌شوند. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: + - `farm_uuid` + - آب‌وهوا از `WeatherForecast` + - خاک/سایت از `SensorData` و داده‌های خاک location + - پارامتر گیاه از `Plant` +- نوع استفاده: مستقیم. + +### 4. `POST /api/crop-simulation/yield-prediction/` +- کاربرد: پیش‌بینی عملکرد مزرعه. +- نقش PCSE: عملکرد پیش‌بینی‌شده از خروجی شبیه‌سازی رشد/خروجی‌های `PCSE` استخراج می‌شود. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: همان ورودی‌های `current-farm-chart` +- نوع استفاده: مستقیم. + +### 5. `POST /api/crop-simulation/harvest-prediction/` +- کاربرد: پیش‌بینی زمان تقریبی برداشت. +- نقش PCSE: با استفاده از `daily_output`، `DVS` و سایر خروجی‌های شبیه‌سازی، زمان رسیدن به برداشت برآورد می‌شود. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: همان ورودی‌های شبیه‌سازی رشد مزرعه +- نوع استفاده: مستقیم. + +### 6. `GET /api/crop-simulation/yield-harvest-summary/` +- کاربرد: خلاصه عملکرد و برداشت. +- نقش PCSE: چند بخش این API مثل `yield_prediction`، `harvest_prediction_card` و `yield_prediction_chart` از خروجی‌های شبیه‌سازی `PCSE` تغذیه می‌شوند. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: + - خروجی `yield_prediction` + - خروجی `harvest_prediction` + - خروجی `current-farm-chart` + - همگی در نهایت متکی به همان ورودی‌های farm/weather/soil/plant هستند +- نوع استفاده: مستقیم/ترکیبی. + +### 7. `POST /api/irrigation/water-stress/` +- کاربرد: محاسبه شاخص تنش آبی مزرعه. +- نقش PCSE: این API از شبیه‌سازی `crop_simulation` استفاده می‌کند و شاخص تنش آبی را با تکیه بر خروجی‌هایی مثل `SM`، `DVS`، `ET0` و `RAIN` می‌سازد. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- ورودی‌ها: + - آب‌وهوا از `WeatherForecast` + - خاک و رطوبت خاک از `SensorData` + - پارامتر گیاه از `Plant` +- نوع استفاده: مستقیم، ولی شاخص نهایی یک فرمول داخلی روی خروجی شبیه‌سازی است. + +## 2) APIهایی که بخشی از تحلیلشان ممکن است با PCSE انجام شود + +### 8. `POST /api/irrigation/recommend/` +- کاربرد: تولید توصیه آبیاری. +- نقش PCSE: در صورت موجود بودن `simulation profile`، داده مزرعه و forecast مناسب، optimizer ابتدا سناریوهای آبیاری را با `PCSE` ارزیابی می‌کند. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- برنامه آبیاری از کجا می‌آید؟ + - ابتدا در خود سیستم چند strategy ساخته می‌شود + - بر اساس `daily_water_needs` و تعداد eventها، برای هر سناریو `irrigation_events` ساخته می‌شود + - این eventها به شکل `TimedEvents` با سیگنال `irrigate` وارد `agromanagement` می‌شوند +- آب‌وهوا از کجا می‌آید؟ + - از forecastهای جدول `WeatherForecast` +- سایر ورودی‌ها از کجا می‌آیند؟ + - خاک و رطوبت و مواد غذایی: از `SensorData` + - گیاه و simulation profile: از `Plant` +- نکته: اگر شرایط کافی نباشد، به مسیر heuristic برمی‌گردد. +- نوع استفاده: مشروط/جزئی. + +### 9. `POST /api/fertilization/recommend/` +- کاربرد: تولید توصیه کودهی. +- نقش PCSE: در صورت موجود بودن `simulation profile` و forecast، سناریوهای کودهی با `PCSE` شبیه‌سازی و امتیازدهی می‌شوند. +- مدل PCSE: `Wofost81_NWLP_CWB_CNB` +- برنامه کودهی از کجا می‌آید؟ + - optimizer چند سناریوی کودهی می‌سازد + - برای هر سناریو event کودهی به شکل `TimedEvents` + - با سیگنال `apply_n` + - و payload شامل `N_amount` و `N_recovery` + - وارد `agromanagement` می‌شود +- آب‌وهوا از کجا می‌آید؟ + - از forecastهای جدول `WeatherForecast` +- سایر ورودی‌ها از کجا می‌آیند؟ + - خاک و وضعیت عناصر از `SensorData` + - پروفایل گیاه از `Plant` +- نکته: اگر `PCSE` یا داده کافی در دسترس نباشد، fallback heuristic استفاده می‌شود. +- نوع استفاده: مشروط/جزئی. + +## 3) APIهایی که از PCSE استفاده نمی‌کنند + +این endpointها در همین حوزه هستند اما خودشان تحلیل مبتنی بر `PCSE` انجام نمی‌دهند: + +- `POST /api/irrigation/plan-from-text/` +- `POST /api/fertilization/plan-from-text/` + +این دو بیشتر parser متن آزاد هستند و برنامه را از متن به JSON ساختاریافته تبدیل می‌کنند. + +## جمع‌بندی کوتاه + +اگر بخواهیم فقط APIهایی را نام ببریم که واقعاً تحلیل یا شبیه‌سازی وابسته به `PCSE` دارند، مهم‌ترین‌ها این‌ها هستند: + +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth//status/` +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/yield-prediction/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/irrigation/water-stress/` +- `POST /api/irrigation/recommend/` (مشروط) +- `POST /api/fertilization/recommend/` (مشروط) + +## جمع‌بندی فنی خیلی کوتاه + +- مدل اصلی `PCSE` در این پروژه: `Wofost81_NWLP_CWB_CNB` +- آب‌وهوا عمدتاً از `WeatherForecast` می‌آید +- خاک و رطوبت و بخشی از وضعیت تغذیه از `SensorData` و داده‌های خاک location می‌آید +- پارامترهای گیاه و setup شبیه‌سازی از `Plant` و `simulation profile` می‌آید +- برنامه آبیاری و کودهی در optimizer ساخته می‌شوند و از طریق `TimedEvents` داخل `agromanagement` به `PCSE` تزریق می‌شوند diff --git a/Modules/Ai/docs/updated_pcse_apis_reference.md b/Modules/Ai/docs/updated_pcse_apis_reference.md new file mode 100644 index 0000000..e134dc9 --- /dev/null +++ b/Modules/Ai/docs/updated_pcse_apis_reference.md @@ -0,0 +1,743 @@ +# مستند کامل APIهای آپدیت‌شده مرتبط با PCSE + +این فایل APIهایی را توضیح می‌دهد که اخیراً آپدیت شده‌اند تا بتوانند `برنامه آبیاری` و `برنامه کودهی` را از ورودی بگیرند و به شبیه‌سازی `PCSE` پاس بدهند. + +APIهای این سند: + +- `POST /api/crop-simulation/growth/` +- `GET /api/crop-simulation/growth//status/` +- `POST /api/crop-simulation/current-farm-chart/` +- `POST /api/crop-simulation/yield-prediction/` +- `POST /api/crop-simulation/harvest-prediction/` +- `GET /api/crop-simulation/yield-harvest-summary/` +- `POST /api/irrigation/water-stress/` + +--- + +## فرمت مشترک `irrigation_recommendation` + +این فیلد در APIهای این سند می‌تواند ارسال شود: + +```json +{ + "events": [ + { + "date": "2026-04-25", + "amount": 2.5, + "efficiency": 0.8 + } + ] +} +``` + +### توضیح فیلدها + +- `events`: آرایه‌ای از رویدادهای آبیاری +- `date`: تاریخ اجرای آبیاری +- `amount`: مقدار آبیاری برای event +- `efficiency`: راندمان آبیاری، اختیاری + +### رفتار در PCSE + +این داده‌ها به `TimedEvents` با سیگنال `irrigate` تبدیل می‌شوند. + +--- + +## فرمت مشترک `fertilization_recommendation` + +```json +{ + "events": [ + { + "date": "2026-04-20", + "N_amount": 45, + "N_recovery": 0.7 + } + ] +} +``` + +### توضیح فیلدها + +- `events`: آرایه‌ای از رویدادهای کودهی +- `date`: تاریخ اجرای کودهی +- `N_amount`: مقدار نیتروژن +- `N_recovery`: ضریب بازیافت/جذب نیتروژن + +### رفتار در PCSE + +این داده‌ها به `TimedEvents` با سیگنال `apply_n` تبدیل می‌شوند. + +--- + +## 1) `POST /api/crop-simulation/growth/` + +### کاربرد + +شروع شبیه‌سازی رشد گیاه به‌صورت async و برگرداندن `task_id`. + +### ورودی + +```json +{ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"], + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "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 + }, + "crop_parameters": {}, + "agromanagement": {}, + "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 +} +``` + +### توضیح پارامترهای ورودی + +- `plant_name`: نام گیاه +- `dynamic_parameters`: لیست پارامترهای دینامیک موردنیاز در خروجی +- `farm_uuid`: شناسه مزرعه؛ اگر باشد، داده مزرعه و forecast از سیستم خوانده می‌شود +- `weather`: آب‌وهوا به‌صورت مستقیم؛ اگر `farm_uuid` نباشد لازم است +- `soil_parameters`: override برای پارامترهای خاک +- `site_parameters`: override برای پارامترهای site +- `crop_parameters`: override برای پارامترهای crop +- `agromanagement`: override برای تقویم زراعی +- `irrigation_recommendation`: برنامه آبیاری برای تزریق به PCSE +- `fertilization_recommendation`: برنامه کودهی برای تزریق به PCSE +- `page_size`: اندازه صفحه مراحل رشد در endpoint وضعیت task + +### اعتبارسنجی + +- حداقل یکی از `farm_uuid` یا `weather` باید ارسال شود +- `dynamic_parameters` نباید خالی باشد +- `page_size` باید بین `1` تا `50` باشد + +### پاسخ موفق + +```json +{ + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": "growth-task-1", + "status_url": "/api/crop-simulation/growth/growth-task-1/status/", + "plant_name": "گوجه‌فرنگی" + } +} +``` + +### توضیح فیلدهای پاسخ + +- `code`: کد داخلی پاسخ +- `msg`: پیام پاسخ +- `data.task_id`: شناسه task +- `data.status_url`: آدرس پیگیری وضعیت task +- `data.plant_name`: نام گیاه + +--- + +## 2) `GET /api/crop-simulation/growth//status/` + +### کاربرد + +بررسی وضعیت اجرای شبیه‌سازی رشد و دریافت نتیجه نهایی. + +### پارامترهای Query + +- `page`: شماره صفحه +- `page_size`: اندازه صفحه + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "task_id": "growth-task-1", + "status": "SUCCESS", + "status_fa": "موفق", + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI", "TAGP"], + "engine": "موتور شبیه سازی PCSE", + "model_name": "مدل ووفوست", + "scenario_id": 12, + "simulation_warning": null, + "summary_metrics": { + "yield_estimate": 5400.0, + "biomass": 9800.0, + "max_lai": 4.2 + }, + "stage_timeline": [ + { + "order": 1, + "stage_code": "vegetative", + "stage_name": "رشد رویشی", + "start_date": "2026-04-01", + "end_date": "2026-04-05", + "days_count": 5, + "metrics": { + "DVS": { + "start": 0.1, + "end": 0.8, + "min": 0.1, + "max": 0.8, + "avg": 0.45 + } + } + } + ], + "stages_page": [], + "pagination": { + "page": 1, + "page_size": 10, + "total_items": 1, + "total_pages": 1, + "has_next": false, + "has_previous": false + }, + "daily_records_count": 14, + "default_page_size": 10 + } + } +} +``` + +### توضیح کامل فیلدهای `result` + +- `plant_name`: نام گیاه +- `dynamic_parameters`: پارامترهای پویا +- `engine`: نام فارسی موتور اجرا +- `model_name`: نام فارسی مدل +- `scenario_id`: شناسه سناریوی ذخیره‌شده +- `simulation_warning`: هشدار fallback یا خطای غیرکشنده +- `summary_metrics.yield_estimate`: برآورد عملکرد +- `summary_metrics.biomass`: بیوماس +- `summary_metrics.max_lai`: بیشینه شاخص سطح برگ +- `stage_timeline`: خلاصه مراحل رشد +- `stages_page`: همان `stage_timeline` به‌صورت صفحه‌بندی‌شده +- `pagination`: متادیتای صفحه‌بندی +- `daily_records_count`: تعداد رکوردهای روزانه +- `default_page_size`: اندازه صفحه پیش‌فرض + +--- + +## 3) `POST /api/crop-simulation/current-farm-chart/` + +### کاربرد + +ساخت chart وضعیت فعلی مزرعه بر اساس شبیه‌سازی. + +### ورودی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": { + "events": [ + { + "date": "2026-04-25", + "amount": 2.5 + } + ] + }, + "fertilization_recommendation": { + "events": [ + { + "date": "2026-04-20", + "N_amount": 45, + "N_recovery": 0.7 + } + ] + } +} +``` + +### توضیح ورودی + +- `farm_uuid`: شناسه مزرعه +- `plant_name`: نام گیاه، اختیاری +- `irrigation_recommendation`: برنامه آبیاری +- `fertilization_recommendation`: برنامه کودهی + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "engine": "موتور شبیه سازی PCSE", + "model_name": "مدل ووفوست", + "scenario_id": 15, + "simulation_warning": null, + "categories": ["2026-04-01", "2026-04-02"], + "series": [ + { + "name": "تعداد برگ تخمینی", + "key": "leaf_count_estimate", + "data": [2400, 3600] + } + ], + "summary": [ + { + "title": "تعداد برگ تخمینی", + "subtitle": "وضعیت فعلی", + "amount": 3600, + "unit": "برگ", + "avatarColor": "success", + "avatarIcon": "tabler-leaf" + } + ], + "current_state": { + "date": "2026-04-02", + "leaf_count_estimate": 3600, + "leaf_area_index": 0.3, + "biomass_weight": 120.5, + "storage_organ_weight": 0.0, + "soil_moisture_percent": 41.2, + "development_stage": 0.15, + "gdd": 12.5 + }, + "metrics": { + "yield_estimate": 5400, + "biomass": 9800, + "max_lai": 4.2 + }, + "daily_output": [] + } +} +``` + +### توضیح کامل پاسخ + +- `categories`: محور تاریخ chart +- `series`: سری‌های نمودار +- `summary`: کارت‌های summary +- `current_state`: وضعیت آخرین روز شبیه‌سازی +- `metrics`: متریک‌های خلاصه شبیه‌سازی +- `daily_output`: خروجی روزانه کامل PCSE + +--- + +## 4) `POST /api/crop-simulation/yield-prediction/` + +### کاربرد + +تبدیل خروجی شبیه‌سازی به پیش‌بینی عملکرد. + +### ورودی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": { + "events": [ + { + "date": "2026-04-25", + "amount": 2.5 + } + ] + }, + "fertilization_recommendation": { + "events": [ + { + "date": "2026-04-20", + "N_amount": 45, + "N_recovery": 0.7 + } + ] + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "predictedYieldTons": 5.4, + "predictedYieldRaw": 5400.0, + "unit": "تن", + "sourceUnit": "کیلوگرم در هکتار", + "simulationEngine": "موتور شبیه سازی PCSE", + "simulationModel": "مدل ووفوست", + "scenarioId": 15, + "simulationWarning": null, + "supportingMetrics": { + "yield_estimate": 5400.0, + "biomass": 9800.0, + "max_lai": 4.2 + } + } +} +``` + +### توضیح فیلدها + +- `predictedYieldTons`: عملکرد پیش‌بینی‌شده بر حسب تن +- `predictedYieldRaw`: عملکرد خام بر حسب کیلوگرم در هکتار +- `unit`: واحد نهایی +- `sourceUnit`: واحد منبع +- `simulationEngine`: نام موتور +- `simulationModel`: نام مدل +- `scenarioId`: شناسه سناریو +- `simulationWarning`: هشدار احتمالی +- `supportingMetrics`: متریک‌های پشتیبان شبیه‌سازی + +--- + +## 5) `POST /api/crop-simulation/harvest-prediction/` + +### کاربرد + +پیش‌بینی زمان تقریبی برداشت. + +### ورودی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": { + "events": [ + { + "date": "2026-04-25", + "amount": 2.5 + } + ] + }, + "fertilization_recommendation": { + "events": [ + { + "date": "2026-04-20", + "N_amount": 45, + "N_recovery": 0.7 + } + ] + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "date": "2026-05-14", + "dateFormatted": "14 May 2026", + "daysUntil": 23, + "description": "توضیح زمان برداشت", + "optimalWindowStart": "2026-05-11", + "optimalWindowEnd": "2026-05-17", + "gddDetails": { + "current_cumulative_gdd": 50, + "required_gdd_for_maturity": 1200, + "remaining_gdd": 1150, + "estimated_days_to_harvest": 23, + "predicted_harvest_date": "2026-05-14", + "predicted_harvest_window": { + "start": "2026-05-11", + "end": "2026-05-17" + }, + "daily_gdd_forecast": [], + "simulation_engine": "pcse", + "simulation_model_name": "Wofost81_NWLP_CWB_CNB", + "simulation_warning": null, + "scenario_id": 18 + } + } +} +``` + +### توضیح فیلدها + +- `date`: تاریخ برداشت پیش‌بینی‌شده +- `dateFormatted`: تاریخ فرمت‌شده برای UI +- `daysUntil`: تعداد روز تا برداشت +- `description`: توضیح خلاصه +- `optimalWindowStart`: شروع بازه بهینه برداشت +- `optimalWindowEnd`: پایان بازه بهینه برداشت +- `gddDetails`: جزئیات مبتنی بر GDD و شبیه‌سازی + +--- + +## 6) `GET /api/crop-simulation/yield-harvest-summary/` + +### کاربرد + +ساخت داشبورد خلاصه عملکرد و برداشت. + +### ورودی + +این API از query string استفاده می‌کند. + +### پارامترهای query + +- `farm_uuid`: شناسه مزرعه +- `season_year`: سال زراعی +- `crop_name`: نام محصول +- `include_narrative`: اگر `true` باشد، تلاش برای تولید narrative می‌شود +- `irrigation_recommendation`: JSON string برنامه آبیاری +- `fertilization_recommendation`: JSON string برنامه کودهی + +### نمونه درخواست + +```text +GET /api/crop-simulation/yield-harvest-summary/?farm_uuid=11111111-1111-1111-1111-111111111111&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}]} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_name": "wheat", + "season_year": "1404", + "title": "خلاصه فصل", + "subtitle": "", + "total_predicted_yield": 5.4, + "yield_unit": "تن", + "target_harvest_date": "2026-05-14", + "days_until_harvest": 23, + "average_readiness": 74, + "primary_quality_grade": "A", + "estimated_revenue": null, + "soil_type": "loam" + }, + "yield_prediction": {}, + "harvest_prediction_card": {}, + "harvest_readiness_zones": {}, + "yield_quality_bands": {}, + "harvest_operations_card": {}, + "yield_prediction_chart": {} + } +} +``` + +### ساختار response + +- `season_highlights_card`: خلاصه فصل +- `yield_prediction`: بلوک پیش‌بینی عملکرد +- `harvest_prediction_card`: بلوک پیش‌بینی برداشت +- `harvest_readiness_zones`: آمادگی برداشت نواحی +- `yield_quality_bands`: باندهای کیفیت محصول +- `harvest_operations_card`: پیشنهاد عملیات برداشت +- `yield_prediction_chart`: نمودار عملکرد و بیوماس + +### توضیح مهم + +این API بخش‌هایی از response را از چند سرویس مختلف می‌سازد، بنابراین بعضی بلوک‌ها ساختار nested و بزرگ دارند. مخصوصاً: + +- `yield_prediction` +- `harvest_prediction_card` +- `yield_prediction_chart` + +این سه بخش مستقیماً تحت تأثیر `irrigation_recommendation` و `fertilization_recommendation` قرار می‌گیرند چون از اجرای PCSE استفاده می‌کنند. + +--- + +## 7) `POST /api/irrigation/water-stress/` + +### کاربرد + +محاسبه شاخص تنش آبی با استفاده از خروجی شبیه‌سازی. + +### ورودی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_recommendation": { + "events": [ + { + "date": "2026-04-25", + "amount": 2.5 + } + ] + }, + "fertilization_recommendation": { + "events": [ + { + "date": "2026-04-20", + "N_amount": 45, + "N_recovery": 0.7 + } + ] + } +} +``` + +### توضیح ورودی + +- `farm_uuid`: شناسه مزرعه +- `sensor_uuid`: نام قدیمی برای `farm_uuid` +- `plant_name`: نام گیاه، اختیاری +- `irrigation_recommendation`: برنامه آبیاری +- `fertilization_recommendation`: برنامه کودهی + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "waterStressIndex": 37, + "level": "متوسط", + "sourceMetric": { + "soilMoisturePercent": 46.0, + "availableWaterRatio": 0.72, + "fieldCapacity": 0.34, + "wiltingPoint": 0.14, + "rootDepthCm": 120.0, + "recentEt0": 0.33, + "recentRain": 1.0, + "soilMoistureDrop": 4.2, + "developmentStage": 1.02, + "stageCode": "flowering", + "stageSensitivity": 1.2, + "engine": "crop_simulation", + "formula": "stress = clamp(((moisture_deficit + et0_pressure + trend_pressure - rainfall_relief - root_depth_relief) * stage_sensitivity), 0, 100)" + } + } +} +``` + +### توضیح فیلدهای response + +- `farm_uuid`: شناسه مزرعه +- `plant_name`: نام گیاه resolved شده +- `waterStressIndex`: شاخص نهایی تنش آبی بین `0` تا `100` +- `level`: سطح تنش آبی (`پایین`، `متوسط`، `بالا`) +- `sourceMetric`: جزئیات محاسبه + +### توضیح `sourceMetric` + +- `soilMoisturePercent`: درصد رطوبت خاک +- `availableWaterRatio`: نسبت آب قابل استفاده +- `fieldCapacity`: ظرفیت مزرعه +- `wiltingPoint`: نقطه پژمردگی +- `rootDepthCm`: عمق ریشه +- `recentEt0`: تبخیر و تعرق مرجع اخیر +- `recentRain`: بارش اخیر +- `soilMoistureDrop`: افت رطوبت خاک +- `developmentStage`: مرحله رشد عددی +- `stageCode`: کد مرحله رشد +- `stageSensitivity`: حساسیت مرحله رشد +- `engine`: منبع محاسبه +- `formula`: فرمول محاسبه شاخص + +--- + +## خطاهای رایج + +### خطای 400 + +وقتی ورودی نامعتبر باشد: + +```json +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": ["farm_uuid الزامی است."] + } +} +``` + +### خطای 404 + +بیشتر در `water-stress` وقتی مزرعه پیدا نشود: + +```json +{ + "code": 404, + "msg": "Farm not found.", + "data": null +} +``` + +### خطای 500 + +وقتی اجرای شبیه‌سازی یا ساخت response شکست بخورد: + +```json +{ + "code": 500, + "msg": "خطا در پیش بینی عملکرد: ...", + "data": null +} +``` + +--- + +## جمع‌بندی + +APIهایی که در این سند توضیح داده شدند، حالا می‌توانند از ورودی کاربر: + +- `irrigation_recommendation` +- `fertilization_recommendation` + +را دریافت کنند و آن‌ها را به لایه شبیه‌سازی `PCSE` پاس بدهند. این یعنی خروجی‌های: + +- رشد +- chart مزرعه +- پیش‌بینی عملکرد +- پیش‌بینی برداشت +- خلاصه Yield/Harvest +- شاخص تنش آبی + +می‌توانند تحت تأثیر برنامه آبیاری و برنامه کودهی ارسالی کاربر قرار بگیرند. diff --git a/Modules/Ai/docs/yield_harvest_pcse_rag_api_plan.md b/Modules/Ai/docs/yield_harvest_pcse_rag_api_plan.md new file mode 100644 index 0000000..16d3a97 --- /dev/null +++ b/Modules/Ai/docs/yield_harvest_pcse_rag_api_plan.md @@ -0,0 +1,746 @@ +# راهنمای پیاده‌سازی API صفحه Yield & Harvest با PCSE و RAG + +این فایل برای تیم بک‌اند نوشته شده تا صفحه `yield-harvest` را با تکیه بر: + +- مستند `psce_doc.txt` پروژه +- سرویس‌های موجود در `crop_simulation/` +- معماری فعلی RAG در `rag/` + +به شکل قابل اتکا پیاده‌سازی کند. + +نکته: فایل `psce_doc.txt` در عمل مستند PCSE است و در این سند هم با عنوان PCSE به آن اشاره می‌شود. + +--- + +## جمع‌بندی سریع + +برای این صفحه، بهترین معماری این است: + +1. عددها، تاریخ‌ها، درصدها و سری‌های نمودار از PCSE و داده‌های مزرعه ساخته شوند. +2. RAG فقط برای متن‌های توضیحی، خلاصه‌سازی، و wording کاربرپسند استفاده شود. +3. RAG اجازه نداشته باشد عدد جدید، تاریخ جدید، یا KPI جدید اختراع کند. +4. یک endpoint خلاصه برای فرانت برگردانده شود: + +`GET /api/yield-harvest/summary/?farm_uuid=` + +اگر لازم شد از نظر convention داخلی پروژه همه‌چیز داخل `crop_simulation` بماند، می‌توانید معادل زیر را هم نگه دارید: + +`GET /api/crop-simulation/yield-harvest-summary/?farm_uuid=` + +برای فرانت فعلی، مدل summary بهتر از چند endpoint پراکنده است. + +--- + +## چیزی که همین الان در پروژه دارید + +### لایه PCSE / شبیه‌سازی + +الان پروژه این اجزا را دارد: + +- `crop_simulation/services.py` +- `crop_simulation/growth_simulation.py` +- `crop_simulation/harvest_prediction.py` +- `crop_simulation/yield_prediction.py` + +و همین حالا از PCSE این خروجی‌ها را می‌سازید: + +- `yield` از `TWSO` +- `biomass` از `TAGP` +- `leaf_area_index` از `LAI` +- `development_stage` از `DVS` +- `soil_moisture` از `SM` + +این دقیقاً با چیزی که در `psce_doc.txt` آمده هم‌راستا است: PCSE برای state/rate variables و خروجی روزانه مناسب است و برای ساخت KPI، trend و harvest timing گزینه خوبی است. + +### لایه RAG + +الان پروژه الگوی خوبی برای سرویس‌های ترکیبی deterministic + RAG دارد: + +- `rag/services/irrigation.py` +- `rag/services/fertilization.py` +- `rag/chat.py` +- `config/rag_config.yaml` + +الگوی درست در این پروژه این است: + +- اول عددها و تصمیم‌های پایه از منطق deterministic ساخته می‌شوند +- بعد RAG فقط متن نهایی را polish می‌کند +- در صورت خراب شدن خروجی LLM، fallback ساختاریافته دارید + +برای `yield-harvest` هم باید دقیقاً همین الگو تکرار شود. + +--- + +## اصل طراحی مهم + +### چه چیزی باید deterministic باشد + +این فیلدها نباید از RAG بیایند: + +- `daysUntil` +- `readiness` +- `share` +- `series[].data` +- `averageReadiness` +- `predicted yield` +- `harvest date` +- هر عددی که در progress bar، chart، KPI یا sorting استفاده می‌شود + +### چه چیزی می‌تواند از RAG بیاید + +این فیلدها مناسب RAG هستند: + +- `season_highlights_card.subtitle` +- `season_highlights_card.spotlight.caption` +- `harvest_prediction_card.description` +- `harvest_operations_card.summary` +- `steps[].note` +- badgeها و labelهای توصیفی کوتاه + +### قانون طلایی + +RAG باید فقط روی context قطعی زیر کار کند: + +- farm details +- خروجی‌های PCSE +- داده‌های آب‌وهوا +- داده‌های سنسور +- در صورت وجود: داده کیفیت، آفات، اقتصاد، بازار + +و prompt آن باید صریح بگوید: + +- عدد جدید نساز +- فقط از مقادیر داده‌شده استفاده کن +- اگر داده کافی نیست، کمبود را بگو +- خروجی را در JSON معتبر برگردان + +--- + +## پیشنهاد معماری برای این صفحه + +بهترین راه این است که یک orchestrator service جدید بسازید: + +- `crop_simulation/yield_harvest_summary.py` + +و داخل آن چند builder کوچک داشته باشید: + +- `_build_yield_prediction_block()` +- `_build_harvest_prediction_block()` +- `_build_yield_prediction_chart_block()` +- `_build_harvest_readiness_zones_block()` +- `_build_yield_quality_bands_block()` +- `_build_harvest_operations_block()` +- `_build_season_highlights_block()` + +و یک facade نهایی: + +- `YieldHarvestSummaryService.get_summary(farm_uuid, season_year=None, crop_name=None)` + +این service باید: + +1. داده مزرعه را از `farm_data.services.get_farm_details()` بگیرد. +2. خروجی شبیه‌سازی را از `CurrentFarmChartSimulator` و سرویس‌های فعلی بگیرد. +3. بلوک‌های deterministic را بسازد. +4. اگر RAG فعال بود، متن‌های narrative را enrich کند. +5. یک payload نهایی برای فرانت برگرداند. + +--- + +## Mapping پیشنهادی هر کارت به منبع داده + +| بلوک | منبع اصلی | وضعیت امکان پیاده‌سازی | +|---|---|---| +| `yield_prediction` | `crop_simulation/yield_prediction.py` + chart metrics | قابل پیاده‌سازی همین الآن | +| `harvest_prediction_card` | `crop_simulation/harvest_prediction.py` | قابل پیاده‌سازی همین الآن | +| `yield_prediction_chart` | `CurrentFarmChartSimulator` + historical baseline | نیمه‌آماده، نیاز به تصمیم درباره سری مقایسه‌ای | +| `season_highlights_card` | aggregate از بقیه بلوک‌ها + RAG | قابل پیاده‌سازی اگر بقیه بلوک‌ها حاضر باشند | +| `harvest_readiness_zones` | داده قطعه/زون + PCSE per zone | فعلاً gap داده‌ای دارد | +| `yield_quality_bands` | quality model / grading rules / lab data | فعلاً gap داده‌ای دارد | +| `harvest_operations_card` | rules + optimizer + RAG | قابل پیاده‌سازی به‌صورت اولیه | + +--- + +## نکته مهم: همه بلوک‌ها را PCSE به‌تنهایی تولید نمی‌کند + +PCSE برای این بخش‌ها خیلی مناسب است: + +- زمان برداشت +- روند رشد +- عملکرد پیش‌بینی‌شده +- stage / maturity / readiness proxy +- trend chart + +اما PCSE به‌صورت پیش‌فرض برای این‌ها کافی نیست: + +- `بریکس` +- `گرید ممتاز / درجه یک / فرآوری` +- `پتانسیل صادرات` +- `قیمت فروش` +- `premium` +- readiness per block وقتی مدل block-level ندارید + +پس برای صفحه کامل، باید سه لایه را از هم جدا ببینید: + +1. `PCSE layer`: عددهای زیستی و رشد +2. `Rules/Aggregation layer`: تبدیل خروجی PCSE به KPIهای محصولی +3. `RAG layer`: توضیح، جمع‌بندی، و متن قابل نمایش + +--- + +## طراحی endpoint پیشنهادی + +### Route + +پیشنهاد اصلی: + +- `GET /api/yield-harvest/summary/?farm_uuid=&season_year=1404&crop_name=wheat` + +### Query params + +- `farm_uuid`: اجباری +- `season_year`: اختیاری +- `crop_name`: اختیاری +- `include_narrative`: اختیاری، پیش‌فرض `true` + +### چرا `include_narrative` خوب است + +چون اگر RAG کند یا موقتاً غیرفعال باشد، باز هم فرانت می‌تواند با داده deterministic رندر شود. + +--- + +## ساختار پیشنهادی response + +همان envelope فعلی پروژه مناسب است: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": {}, + "yield_prediction": {}, + "harvest_prediction_card": {}, + "harvest_readiness_zones": {}, + "yield_quality_bands": {}, + "harvest_operations_card": {}, + "yield_prediction_chart": {} + } +} +``` + +--- + +## پیشنهاد فنی برای ساخت هر بلوک + +## 1) `yield_prediction` + +### منبع + +- `YieldPredictionService` +- یا مستقیم `CurrentFarmChartSimulator.simulate()` + +### منطق پیشنهادی + +- `predicted yield` از `TWSO` +- `harvest readiness` از `DVS` و فاصله تا maturity +- `quality score` فعلاً rule-based، نه RAG-based +- `loss risk` از ترکیب moisture/weather/pest signals اگر دارید، وگرنه با fallback ساده + +### توصیه + +اگر الآن فقط داده قطعی می‌خواهید: + +- KPI اول را دقیق بسازید +- KPIهای دوم تا چهارم را با rules ساده و شفاف بسازید +- آن‌ها را به عنوان `estimated` در کد داخلی mark کنید + +--- + +## 2) `harvest_prediction_card` + +### منبع + +- `crop_simulation/harvest_prediction.py` + +### وضعیت + +این بلوک تقریباً آماده است. + +### توصیه + +- `dateFormatted` را از serializer-ready formatter بگیرید +- `daysUntil` حتماً `number` بماند +- متن `description` اگر خواستید با RAG بهتر شود، version deterministic را هم نگه دارید + +یعنی بهتر است این دو فیلد را داشته باشید: + +- `description` +- `descriptionSource = "deterministic" | "rag"` + +این source flag برای debugging خیلی کمک می‌کند. + +--- + +## 3) `yield_prediction_chart` + +### واقعیت فعلی + +خروجی chart فعلی شما در `CurrentFarmChartSimulator` بیشتر این‌ها را می‌دهد: + +- leaf count estimate +- biomass +- storage organ weight +- lai +- soil moisture + +اما payload فرانت `yield-harvest` در سند شما نمودار `سال قبل / سال جاری` می‌خواهد. + +### نتیجه + +این بخش هنوز one-to-one با کد فعلی آماده نیست. + +### راه درست + +یکی از این دو مسیر را انتخاب کنید: + +#### مسیر A - سریع‌تر + +همان chart فعلی را به contract فرانت نزدیک کنید و فعلاً به‌جای `سال قبل/سال جاری` از: + +- `عملکرد تجمعی پیش‌بینی‌شده` +- `بیوماس` + +استفاده کنید. + +#### مسیر B - محصولی‌تر + +دو سری واقعی تولید کنید: + +- `سال جاری`: از simulation جاری +- `سال قبل`: از historical scenario یا historical harvest data + +اگر داده سال قبل ندارید، این سری را fake نکنید. + +### توصیه نهایی + +برای production بهتر است تا وقتی historical source ندارید، label سری‌ها را صادقانه تغییر دهید. + +--- + +## 4) `harvest_readiness_zones` + +### بزرگ‌ترین gap فعلی + +مدل داده فعلی شما عمدتاً farm-level است: + +- `SensorData` +- `center_location` +- `farm_details` + +اما این کارت block-level data می‌خواهد: + +- `A1` +- `A2` +- cultivar per block +- readiness per block + +### نتیجه + +بدون model یا source جدید برای block/zone، این کارت را نباید fake کنید. + +### راه درست + +یا باید یکی از این‌ها را اضافه کنید: + +1. مدل `FarmBlock` یا `FarmZone` +2. چند sensor/polygon برای هر مزرعه +3. block metadata در JSON مزرعه + +### پیاده‌سازی پیشنهادی + +برای هر block: + +1. weather/location را resolve کنید +2. crop parameters block-specific بسازید +3. یک simulation جدا اجرا کنید +4. `DVS`, `TWSO`, `SM`, `maturity date` را بگیرید +5. readiness را از rule زیر بسازید + +مثال rule: + +```text +readiness = clamp((current_dvs / 2.0) * 100, 0, 100) +``` + +یا: + +```text +readiness = clamp(100 - days_until_harvest * 4, 0, 100) +``` + +بهتر است این rule داخل code comment شفاف توضیح داده شود. + +--- + +## 5) `yield_quality_bands` + +### نکته مهم + +PCSE به‌صورت پیش‌فرض grade یا brix تولید نمی‌کند. + +پس این کارت را باید از یکی از این منابع ساخت: + +1. داده آزمایشگاهی +2. rule engine بر اساس moisture + disease risk + maturity + crop profile +3. مدل quality جدا + +### چیزی که نباید انجام شود + +این‌که RAG خودش بگوید: + +- 46% گرید ممتاز +- 34% گرید یک + +بدون منطق deterministic + +این کار برای dashboard اشتباه است. + +### پیشنهاد عملی + +فاز اول: + +- اگر quality data ندارید، این کارت را با flag `unavailable` برگردانید +- یا bands را از rule-based scoring بسازید و در backend صریح mark کنید که estimated هستند + +مثلاً: + +- `quality_score >= 85 -> premium` +- `70..84 -> grade_1` +- `< 70 -> processing` + +اما این rule باید crop-specific باشد، نه universal. + +--- + +## 6) `harvest_operations_card` + +### بهترین جا برای ترکیب deterministic + RAG + +این کارت candidate خیلی خوبی برای RAG است، چون: + +- order و timing می‌تواند از rules و simulation بیاید +- متن summary و noteها می‌تواند از RAG بیاید + +### طراحی پیشنهادی + +ابتدا backend یک payload قطعی بسازد: + +```json +{ + "recommended_shift_count": 2, + "sorting_capacity_ton_per_day": 15, + "required_workers": 12, + "max_transfer_hours": 6, + "priority_blocks": ["A1", "A2"], + "reasoning": [ + "A1 readiness بالاتر دارد", + "moisture در محدوده مناسب است" + ] +} +``` + +بعد این payload را به RAG بدهید تا فقط این‌ها را تولید کند: + +- `summary` +- `steps[].note` +- `steps[].status` + +و حتی‌المقدور: + +- `outputs[]` را deterministic نگه دارید + +--- + +## 7) `season_highlights_card` + +### بهترین روش + +این کارت را از بقیه بلوک‌ها derive کنید، نه از یک منبع مستقل. + +یعنی: + +- `seasonLabel` از ورودی season +- `badges` از readiness/quality/risk +- `spotlight.value` از harvest window یا best sale window +- `metrics` از yield, grade, revenue estimate + +### نکته + +اگر هنوز economy data ندارید، فیلدهایی مثل: + +- `درآمد هدف` +- `پنجره طلایی فروش` + +نباید با RAG اختراع شوند. + +یا باید: + +- حذف شوند +- یا با `null` +- یا با fallback business rule + +برگردند. + +--- + +## پیشنهاد برای RAG service جدید + +مثل `irrigation` و `fertilization` یک سرویس جدید اضافه کنید: + +- `rag/services/yield_harvest.py` + +و در `config/rag_config.yaml` هم service جدید تعریف کنید: + +- `yield_harvest` + +### نقش این سرویس + +این سرویس نباید خودش KPI بسازد. + +فقط باید: + +- summary بنویسد +- subtitle بسازد +- operation notes تولید کند +- badge text را human-friendly کند + +### ورودی پیشنهادی به RAG + +```json +{ + "farm_uuid": "...", + "plant_name": "...", + "deterministic_metrics": { + "predicted_yield_tons": 42.8, + "days_until_harvest": 18, + "readiness_avg": 84, + "quality_score": 91, + "loss_risk_percent": 6.5 + }, + "operations": { + "shift_count": 2, + "required_workers": 12, + "max_transfer_hours": 6 + } +} +``` + +### خروجی پیشنهادی از RAG + +فقط این بخش‌ها: + +```json +{ + "season_highlights_card": { + "subtitle": "...", + "spotlight": { + "caption": "..." + }, + "badges": ["...", "..."] + }, + "harvest_prediction_card": { + "description": "..." + }, + "harvest_operations_card": { + "summary": "...", + "steps": [ + { + "title": "...", + "note": "...", + "status": "..." + } + ] + } +} +``` + +### Rule مهم + +اگر JSON RAG خراب بود: + +- fallback deterministic برگردان +- endpoint fail نکند + +--- + +## فایل‌هایی که پیشنهاد می‌شود تغییر کنند + +### در `crop_simulation/` + +- `crop_simulation/apps.py` + - اضافه کردن getter برای summary service + +- `crop_simulation/serializers.py` + - serializer درخواست و پاسخ summary + +- `crop_simulation/views.py` + - view جدید برای summary + +- `crop_simulation/urls.py` + - route جدید + +- `crop_simulation/yield_harvest_summary.py` + - orchestrator اصلی + +### در `Schemas/` + +- `Schemas/crop_simulation_yield_harvest_summary.py` + - contract برای مستندسازی route + +- `Schemas/__init__.py` + - register کردن contract جدید + +### در `rag/` + +- `rag/services/yield_harvest.py` + - narrative enrichment + +### در config + +- `config/rag_config.yaml` + - service جدید `yield_harvest` + +- `config/tones/yield_harvest_tone.txt` + - tone مخصوص summary صفحه + +- در صورت نیاز: + - `config/knowledge_base/yield_harvest/` + +--- + +## ترتیب پیشنهادی پیاده‌سازی + +### فاز 1 - سریع و قابل اتکا + +فقط این بلوک‌ها را واقعی کنید: + +- `yield_prediction` +- `harvest_prediction_card` +- `yield_prediction_chart` +- `harvest_operations_card` با rules ساده + +و برای بقیه اگر داده ندارید: + +- `null` +- یا `available: false` + +برگردانید. + +### فاز 2 - RAG narrative + +به summary و operation notes متن کاربرپسند اضافه کنید. + +### فاز 3 - block-level readiness + +بعد از اضافه شدن مدل zone/block، `harvest_readiness_zones` را واقعی کنید. + +### فاز 4 - quality/economy + +بعد از اضافه شدن منبع quality یا market: + +- `yield_quality_bands` +- revenue +- sale window + +را کامل کنید. + +--- + +## توصیه مهم درباره contract + +اگر بعضی بلوک‌ها هنوز data source واقعی ندارند، بهتر است از همین حالا response را این‌طور طراحی کنید: + +```json +{ + "harvest_readiness_zones": { + "available": false, + "reason": "block_level_data_missing", + "averageReadiness": null, + "blocks": [] + } +} +``` + +این بهتر از mock پنهان است، چون: + +- فرانت می‌فهمد چرا کارت کامل نیست +- QA راحت‌تر تست می‌کند +- بعداً migration ساده‌تر می‌شود + +--- + +## ریسک‌های اصلی + +### 1. استفاده از RAG برای عددسازی + +بزرگ‌ترین خطر این صفحه همین است. RAG فقط برای narrative مناسب است. + +### 2. نبود داده block-level + +`harvest_readiness_zones` بدون مدل block عملاً دقیق نمی‌شود. + +### 3. نبود quality source + +`yield_quality_bands` بدون مدل کیفیت یا rule engine crop-specific قابل اتکا نیست. + +### 4. ناهماهنگی chart contract + +chart فعلی backend دقیقاً همان chart مورد انتظار فرانت نیست. + +--- + +## پیشنهاد نهایی من + +اگر بخواهم برای همین repo کم‌ریسک‌ترین مسیر را انتخاب کنم: + +1. یک summary service در `crop_simulation` بسازید. +2. `yield_prediction` و `harvest_prediction_card` را از سرویس‌های فعلی reuse کنید. +3. `yield_prediction_chart` را فعلاً با داده واقعی current simulation برگردانید، نه مقایسه fake سال قبل. +4. `harvest_operations_card` را با rules deterministic + optional RAG summary بسازید. +5. `season_highlights_card` را از همین بلوک‌ها aggregate کنید. +6. `harvest_readiness_zones` و `yield_quality_bands` را فقط وقتی source واقعی دارید production کنید. + +این مسیر با معماری فعلی پروژه، با `psce_doc.txt`، و با pattern موجود RAG بیشترین سازگاری را دارد. + +--- + +## یک pseudo flow پیشنهادی + +```text +GET /api/yield-harvest/summary/?farm_uuid=... + -> +load farm details + -> +run/reuse PCSE simulation outputs + -> +build deterministic blocks + -> +optionally call RAG for narrative fields only + -> +merge response + -> +return code/msg/data +``` + +--- + +## نتیجه + +برای این صفحه: + +- PCSE باید موتور عددها باشد +- rules باید موتور KPIهای محصولی باشد +- RAG باید موتور متن و explanation باشد + +اگر این مرزبندی رعایت شود، هم dashboard قابل اعتماد می‌شود، هم توسعه بعدی آن تمیز و قابل نگهداری می‌ماند. diff --git a/Modules/Ai/economy/__init__.py b/Modules/Ai/economy/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/economy/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/economy/apps.py b/Modules/Ai/economy/apps.py new file mode 100644 index 0000000..bc317e5 --- /dev/null +++ b/Modules/Ai/economy/apps.py @@ -0,0 +1,18 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class EconomyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "economy" + verbose_name = "Economy" + + @cached_property + def economic_overview_service(self): + from .services import EconomicOverviewService + + return EconomicOverviewService() + + def get_economic_overview_service(self): + return self.economic_overview_service diff --git a/Modules/Ai/economy/serializers.py b/Modules/Ai/economy/serializers.py new file mode 100644 index 0000000..6f85d4f --- /dev/null +++ b/Modules/Ai/economy/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + + +class EconomicOverviewRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class EconomicDataItemSerializer(serializers.Serializer): + title = serializers.CharField() + value = serializers.CharField() + subtitle = serializers.CharField() + avatarIcon = serializers.CharField() + avatarColor = serializers.CharField() + + +class EconomicChartSeriesSerializer(serializers.Serializer): + name = serializers.CharField() + data = serializers.ListField(child=serializers.FloatField()) + + +class EconomicOverviewResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + source = serializers.CharField() + economicData = EconomicDataItemSerializer(many=True) + chartSeries = EconomicChartSeriesSerializer(many=True) + chartCategories = serializers.ListField(child=serializers.CharField()) diff --git a/Modules/Ai/economy/services.py b/Modules/Ai/economy/services.py new file mode 100644 index 0000000..9e906cf --- /dev/null +++ b/Modules/Ai/economy/services.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Any + + +class EconomicOverviewService: + def get_economic_overview(self, *, farm_uuid: str) -> dict[str, Any]: + raise NotImplementedError( + f"Economic overview has no real data source configured for farm {farm_uuid}." + ) diff --git a/Modules/Ai/economy/test_economic_overview_api.py b/Modules/Ai/economy/test_economic_overview_api.py new file mode 100644 index 0000000..2fc8177 --- /dev/null +++ b/Modules/Ai/economy/test_economic_overview_api.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + + +@override_settings(ROOT_URLCONF="economy.urls") +class EconomicOverviewApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + def test_economic_overview_api_returns_service_unavailable_without_real_data(self): + response = self.client.post( + "/overview/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 503) + self.assertIsNone(response.json()["data"]) diff --git a/Modules/Ai/economy/urls.py b/Modules/Ai/economy/urls.py new file mode 100644 index 0000000..9d5eb80 --- /dev/null +++ b/Modules/Ai/economy/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import EconomicOverviewView + + +urlpatterns = [ + path("overview/", EconomicOverviewView.as_view(), name="economic-overview"), +] diff --git a/Modules/Ai/economy/views.py b/Modules/Ai/economy/views.py new file mode 100644 index 0000000..98e8900 --- /dev/null +++ b/Modules/Ai/economy/views.py @@ -0,0 +1,76 @@ +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response + +from .serializers import ( + EconomicOverviewRequestSerializer, + EconomicOverviewResponseSerializer, +) + + +EconomicOverviewEnvelopeSerializer = build_envelope_serializer( + "EconomicOverviewEnvelopeSerializer", + EconomicOverviewResponseSerializer, +) +EconomyErrorSerializer = build_envelope_serializer( + "EconomyErrorSerializer", + data_required=False, + allow_null=True, +) + + +class EconomicOverviewView(APIView): + @extend_schema( + tags=["Economy"], + summary="دریافت نمای اقتصادی مزرعه", + description="با دریافت farm_uuid، نمای اقتصادی مزرعه را از منبع واقعی برمی گرداند.", + request=EconomicOverviewRequestSerializer, + responses={ + 200: build_response( + EconomicOverviewEnvelopeSerializer, + "نمای اقتصادی مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + EconomyErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 503: build_response( + EconomyErrorSerializer, + "منبع داده نمای اقتصادی در دسترس نیست.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست economy", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = EconomicOverviewRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("economy").get_economic_overview_service() + try: + data = service.get_economic_overview( + farm_uuid=str(serializer.validated_data["farm_uuid"]) + ) + except NotImplementedError as exc: + return Response( + {"code": 503, "msg": str(exc), "data": None}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/entrypoint.sh b/Modules/Ai/entrypoint.sh new file mode 100644 index 0000000..2b4247b --- /dev/null +++ b/Modules/Ai/entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -e + +wait_for_db() { + python - <<'PY' +import os +import socket +import sys +import time + +host = os.environ.get("DB_HOST", "db") +port = int(os.environ.get("DB_PORT", "3306")) +deadline = time.time() + 90 + +while time.time() < deadline: + try: + with socket.create_connection((host, port), timeout=3): + print(f"Database is reachable at {host}:{port}") + sys.exit(0) + except OSError as exc: + print(f"Waiting for database {host}:{port}... {exc}") + time.sleep(2) + +print(f"Timed out waiting for database {host}:{port}", file=sys.stderr) +sys.exit(1) +PY +} + +if [ "${SKIP_MIGRATE}" != "1" ]; then + wait_for_db + echo "Running migrations..." + python manage.py repair_location_tables + python manage.py migrate --noinput + echo "Migrations done." +fi + +if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then + echo "DEVELOP is set. Seeding demo plant, location_data, weather_data, and farm_data..." + python manage.py seed_plants + python manage.py seed_location_data + python manage.py seed_weather_data + python manage.py seed_farm_data + echo "Demo seeders done." +fi + +echo "Collecting static files..." +python manage.py collectstatic --noinput +echo "Static files ready." + +echo "Starting command: $*" +exec "$@" diff --git a/Modules/Ai/farm_alerts/__init__.py b/Modules/Ai/farm_alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_alerts/alerts_tracker.py b/Modules/Ai/farm_alerts/alerts_tracker.py new file mode 100644 index 0000000..fb971a2 --- /dev/null +++ b/Modules/Ai/farm_alerts/alerts_tracker.py @@ -0,0 +1,562 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from typing import Any + +from django.utils import timezone + + +def safe_number(value, default=0): + return default if value is None else value + + +SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} +SEVERITY_UI = { + "low": {"avatarColor": "info", "chipColor": "info"}, + "medium": {"avatarColor": "warning", "chipColor": "warning"}, + "high": {"avatarColor": "error", "chipColor": "error"}, + "critical": {"avatarColor": "error", "chipColor": "error"}, +} +METRIC_META = { + "moisture": { + "title": "تنش رطوبتی", + "icon": "tabler-droplet-half-2", + "unit": "%", + "domain": "water_balance", + "threshold": 45.0, + "danger_span": 20.0, + "direction": "below", + }, + "temperature": { + "title": "تنش دمایی", + "icon": "tabler-snowflake", + "unit": "°C", + "domain": "temperature_stress", + "threshold": 0.0, + "danger_span": 8.0, + "direction": "below", + }, + "ph": { + "title": "عدم تعادل pH", + "icon": "tabler-flask", + "unit": "pH", + "domain": "root_chemistry", + "threshold_low": 6.0, + "threshold_high": 7.5, + "danger_span": 1.5, + }, + "ec": { + "title": "شوری / EC بالا", + "icon": "tabler-bolt", + "unit": "dS/m", + "domain": "root_chemistry", + "threshold": 3.0, + "danger_span": 2.0, + "direction": "above", + }, + "fungal_risk": { + "title": "ریسک قارچی", + "icon": "tabler-mushroom", + "unit": "%", + "domain": "disease_pressure", + "threshold": 70.0, + "danger_span": 20.0, + "direction": "above", + }, +} + +SUMMARY_TEMPLATES = { + "moisture": { + "low": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "medium": "رطوبت خاک پایین‌تر از محدوده مطلوب است و برنامه آبیاری باید بازبینی شود.", + "high": "تنش آبی قابل‌توجه شناسایی شده و مزرعه به اقدام آبیاری سریع نیاز دارد.", + "critical": "کمبود شدید رطوبت فعال است و خطر افت رشد یا آسیب ریشه بالا رفته است.", + }, + "temperature": { + "low": "دمای پایین ثبت شده و باید روند شبانه پایش شود.", + "medium": "ریسک سرمازدگی ایجاد شده و اقدامات محافظتی باید آماده شود.", + "high": "سرما به محدوده پرخطر رسیده و حفاظت دمایی باید در اولویت باشد.", + "critical": "یخبندان بحرانی پیش‌بینی یا مشاهده شده و اقدام فوری حفاظتی لازم است.", + }, + "ph": { + "low": "pH از محدوده مطلوب فاصله گرفته و نیاز به بررسی اصلاحی دارد.", + "medium": "عدم تعادل pH می‌تواند جذب عناصر را مختل کند و باید اصلاح شود.", + "high": "انحراف pH شدید است و ریسک اختلال تغذیه گیاه بالا رفته است.", + "critical": "pH در وضعیت بحرانی قرار دارد و مداخله سریع برای جلوگیری از تنش تغذیه‌ای لازم است.", + }, + "ec": { + "low": "EC بالاتر از حد مرجع است و باید روند شوری پیگیری شود.", + "medium": "شوری خاک می‌تواند رشد را محدود کند و نیاز به تعدیل دارد.", + "high": "EC بالا به سطح پرخطر رسیده و مدیریت شوری باید انجام شود.", + "critical": "شوری بحرانی فعال است و احتمال آسیب ریشه و افت جذب آب بسیار بالاست.", + }, + "fungal_risk": { + "low": "شرایط اولیه برای فشار بیماری قارچی مشاهده شده است.", + "medium": "رطوبت و خیس‌ماندگی بستر، ریسک بیماری قارچی را افزایش داده است.", + "high": "فشار بیماری قارچی بالا است و عملیات پیشگیرانه باید در اولویت قرار گیرد.", + "critical": "الگوی بسیار پرخطر بیماری قارچی فعال است و اقدام فوری محافظتی لازم است.", + }, +} + +ACTION_TEMPLATES = { + "moisture": { + "low": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "medium": "یک نوبت آبیاری اصلاحی برنامه‌ریزی و افت رطوبت در عمق‌های مختلف پایش شود.", + "high": "آبیاری جبرانی کوتاه‌مدت اجرا و راندمان روش آبیاری بازبینی شود.", + "critical": "آبیاری اضطراری، بررسی انسداد سامانه و پایش مجدد سنسور فوراً انجام شود.", + }, + "temperature": { + "low": "پوشش یا برنامه محافظتی شبانه آماده نگه داشته شود.", + "medium": "زمان‌بندی آبیاری و پوشش حفاظتی برای ساعات سرد تنظیم شود.", + "high": "اقدامات ضدیخبندان مانند آبیاری حفاظتی یا پوشش فوری اجرا شود.", + "critical": "پروتکل کامل حفاظت سرما فوراً فعال و وضعیت مزرعه در چند ساعت بعدی بازبینی شود.", + }, + "ph": { + "low": "نمونه‌برداری تکمیلی انجام و روند pH برای چند قرائت بعدی کنترل شود.", + "medium": "برنامه اصلاح pH با توجه به نوع خاک و کود مصرفی بازتنظیم شود.", + "high": "اصلاح‌کننده مناسب خاک در اولویت قرار گیرد و تغذیه گیاه بازبینی شود.", + "critical": "مداخله اصلاحی فوری برای pH انجام و مصرف نهاده‌های تشدیدکننده متوقف شود.", + }, + "ec": { + "low": "منبع آب و روند EC در روزهای آینده کنترل شود.", + "medium": "شست‌وشوی محدود خاک یا اصلاح برنامه کوددهی بررسی شود.", + "high": "کاهش بار نمکی، بازبینی کوددهی و ارزیابی زهکشی در اولویت قرار گیرد.", + "critical": "اقدام فوری برای کاهش شوری و توقف نهاده‌های شورکننده انجام شود.", + }, + "fungal_risk": { + "low": "تهویه و رطوبت بستر پایش شود و نشانه‌های اولیه بیماری بررسی گردد.", + "medium": "فاصله آبیاری و تهویه مزرعه تنظیم و بازدید بیماری انجام شود.", + "high": "اقدامات پیشگیرانه بیماری و کاهش رطوبت ماندگار فوراً اجرا شود.", + "critical": "پروتکل فوری مدیریت بیماری فعال و مزرعه از نظر آلودگی کانونی بررسی شود.", + }, +} + +EXPLANATION_TEMPLATES = { + "moisture": { + "low": "رطوبت فعلی {current_value}{unit} به زیر آستانه {threshold_value}{unit} رسیده است و این وضعیت {duration_text} ادامه داشته است.", + "medium": "رطوبت خاک {current_value}{unit} است؛ فاصله از آستانه {threshold_value}{unit} و تداوم {duration_text} نشان‌دهنده تنش آبی است.", + "high": "رطوبت خاک در {current_value}{unit} ثبت شده که به‌طور معنی‌دار پایین‌تر از آستانه {threshold_value}{unit} است و {duration_text} پایدار مانده است.", + "critical": "رطوبت خاک به {current_value}{unit} سقوط کرده و با عبور شدید از آستانه {threshold_value}{unit}، {duration_text} در وضعیت بحرانی باقی مانده است.", + }, + "temperature": { + "low": "دما به {current_value}{unit} رسیده که از حد هشدار {threshold_value}{unit} پایین‌تر است و {duration_text} تداوم داشته است.", + "medium": "دمای ثبت‌شده {current_value}{unit} کمتر از آستانه {threshold_value}{unit} است و تداوم {duration_text} ریسک تنش سرما را بالا برده است.", + "high": "افت دما تا {current_value}{unit} همراه با ماندگاری {duration_text} شرایط پرخطر سرما را ایجاد کرده است.", + "critical": "دمای {current_value}{unit} با ماندگاری {duration_text} نشان می‌دهد مزرعه در معرض یخبندان بحرانی قرار دارد.", + }, + "ph": { + "low": "pH فعلی {current_value}{unit} از محدوده مرجع {threshold_value} خارج شده و این انحراف {duration_text} ادامه داشته است.", + "medium": "انحراف pH تا {current_value}{unit} نسبت به حد مجاز {threshold_value} همراه با تداوم {duration_text} می‌تواند جذب عناصر را مختل کند.", + "high": "pH {current_value}{unit} با فاصله زیاد از محدوده مرجع و پایداری {duration_text} یک تنش شیمیایی مهم ایجاد کرده است.", + "critical": "وضعیت بحرانی pH در سطح {current_value}{unit} و با تداوم {duration_text} نیاز به اصلاح فوری دارد.", + }, + "ec": { + "low": "EC فعلی {current_value}{unit} از آستانه {threshold_value}{unit} عبور کرده و {duration_text} پایدار مانده است.", + "medium": "EC برابر {current_value}{unit} است؛ عبور از حد {threshold_value}{unit} با ماندگاری {duration_text} فشار شوری را افزایش داده است.", + "high": "شوری ثبت‌شده در {current_value}{unit} با تداوم {duration_text} به سطح پرخطر رسیده است.", + "critical": "EC در {current_value}{unit} و با پایداری {duration_text} نشان‌دهنده شوری بحرانی خاک است.", + }, + "fungal_risk": { + "low": "رطوبت هوا و خاک شرایط اولیه فشار قارچی را ایجاد کرده و این الگو {duration_text} ادامه داشته است.", + "medium": "ترکیب رطوبت {current_value}{unit} و ماندگاری {duration_text} از آستانه {threshold_value}{unit} عبور کرده و ریسک قارچی را بالا برده است.", + "high": "شرایط مرطوب پایدار در {current_value}{unit} و تداوم {duration_text} فشار قارچی جدی ایجاد کرده است.", + "critical": "ماندگاری طولانی شرایط بسیار مرطوب ({current_value}{unit}) در برابر حد {threshold_value}{unit} نشان‌دهنده ریسک بحرانی بیماری قارچی است.", + }, +} + +CLUSTER_TITLES = { + "water_balance": "تعادل آب", + "temperature_stress": "تنش دمایی", + "root_chemistry": "شیمی ناحیه ریشه", + "disease_pressure": "فشار بیماری", +} + + +def _now() -> datetime: + return timezone.now() + + +def _timestamp_for(obj: Any, fallback: datetime) -> datetime: + for attr in ("recorded_at", "updated_at", "created_at", "forecast_date"): + value = getattr(obj, attr, None) + if value is not None: + if isinstance(value, datetime): + return value + return datetime.combine(value, datetime.min.time(), tzinfo=fallback.tzinfo) + return fallback + + +def _format_timestamp(value: datetime) -> str: + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return value.isoformat() + + +def _format_duration(hours: float) -> str: + rounded = max(1, round(hours)) + if rounded >= 24: + days = rounded // 24 + rem_hours = rounded % 24 + if rem_hours == 0: + return f"{days} روز" + return f"{days} روز و {rem_hours} ساعت" + return f"{rounded} ساعت" + + +def _severity_from_score(score: float) -> str: + if score >= 0.85: + return "critical" + if score >= 0.55: + return "high" + if score >= 0.3: + return "medium" + return "low" + + +def _build_severity(distance_ratio: float, duration_hours: float) -> str: + duration_ratio = min(duration_hours / 72.0, 1.0) + score = min((distance_ratio * 0.7) + (duration_ratio * 0.3), 1.0) + return _severity_from_score(score) + + +def _collect_active_history_duration( + current_value: float, + history: list[Any], + field_name: str, + threshold: float, + direction: str, + fallback_timestamp: datetime, +) -> tuple[float, datetime]: + if direction == "below": + is_violating = lambda value: value < threshold + else: + is_violating = lambda value: value > threshold + + if not is_violating(current_value): + return 0.0, fallback_timestamp + + violating_times = [fallback_timestamp] + for item in history: + value = getattr(item, field_name, None) + if value is None: + break + if not is_violating(value): + break + violating_times.append(_timestamp_for(item, fallback_timestamp)) + + oldest_violation = min(violating_times) + duration_hours = max((_now() - oldest_violation).total_seconds() / 3600, 1.0) + return duration_hours, oldest_violation + + +def _make_alert( + metric_type: str, + current_value: float, + threshold_value: float | str, + severity: str, + duration_hours: float, + timestamp: datetime, + sensor_id: str, + zone_id: str | None = None, + direction: str | None = None, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any]: + meta = METRIC_META[metric_type] + unit = meta["unit"] + threshold_display = threshold_value + if isinstance(threshold_value, float): + threshold_display = round(threshold_value, 2) + + explanation = EXPLANATION_TEMPLATES[metric_type][severity].format( + current_value=round(current_value, 2), + threshold_value=threshold_display, + unit=unit, + duration_text=_format_duration(duration_hours), + ) + return { + "metric_type": metric_type, + "title": meta["title"], + "current_value": round(current_value, 2), + "threshold_value": threshold_display, + "severity": severity, + "duration_hours": round(duration_hours, 1), + "duration": _format_duration(duration_hours), + "timestamp": _format_timestamp(timestamp), + "sensor_id": sensor_id, + "zone_id": zone_id, + "domain": meta["domain"], + "direction": direction, + "unit": unit, + "icon": meta["icon"], + "summary": SUMMARY_TEMPLATES[metric_type][severity], + "recommended_action": ACTION_TEMPLATES[metric_type][severity], + "explanation": explanation, + "metadata": metadata or {}, + } + + +def _detect_moisture_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]: + current_value = safe_number(getattr(sensor, "soil_moisture", None), 0) + meta = METRIC_META["moisture"] + threshold = meta["threshold"] + if current_value >= threshold: + return [] + + timestamp = _timestamp_for(sensor, _now()) + duration_hours, started_at = _collect_active_history_duration( + current_value=current_value, + history=history, + field_name="soil_moisture", + threshold=threshold, + direction=meta["direction"], + fallback_timestamp=timestamp, + ) + distance_ratio = min((threshold - current_value) / meta["danger_span"], 1.0) + severity = _build_severity(distance_ratio, duration_hours) + return [ + _make_alert( + metric_type="moisture", + current_value=current_value, + threshold_value=threshold, + severity=severity, + duration_hours=duration_hours, + timestamp=started_at, + sensor_id=sensor_id, + direction=meta["direction"], + ) + ] + + +def _detect_ph_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]: + current_value = safe_number(getattr(sensor, "soil_ph", None), 7) + meta = METRIC_META["ph"] + low = meta["threshold_low"] + high = meta["threshold_high"] + if low <= current_value <= high: + return [] + + direction = "below" if current_value < low else "above" + threshold = low if direction == "below" else high + timestamp = _timestamp_for(sensor, _now()) + duration_hours, started_at = _collect_active_history_duration( + current_value=current_value, + history=history, + field_name="soil_ph", + threshold=threshold, + direction=direction, + fallback_timestamp=timestamp, + ) + distance_ratio = min(abs(current_value - threshold) / meta["danger_span"], 1.0) + severity = _build_severity(distance_ratio, duration_hours) + threshold_display = f"{low}-{high}" + return [ + _make_alert( + metric_type="ph", + current_value=current_value, + threshold_value=threshold_display, + severity=severity, + duration_hours=duration_hours, + timestamp=started_at, + sensor_id=sensor_id, + direction=direction, + metadata={"boundary_threshold": threshold}, + ) + ] + + +def _detect_ec_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]: + current_value = safe_number(getattr(sensor, "electrical_conductivity", None), 0) + meta = METRIC_META["ec"] + threshold = meta["threshold"] + if current_value <= threshold: + return [] + + timestamp = _timestamp_for(sensor, _now()) + duration_hours, started_at = _collect_active_history_duration( + current_value=current_value, + history=history, + field_name="electrical_conductivity", + threshold=threshold, + direction=meta["direction"], + fallback_timestamp=timestamp, + ) + distance_ratio = min((current_value - threshold) / meta["danger_span"], 1.0) + severity = _build_severity(distance_ratio, duration_hours) + return [ + _make_alert( + metric_type="ec", + current_value=current_value, + threshold_value=threshold, + severity=severity, + duration_hours=duration_hours, + timestamp=started_at, + sensor_id=sensor_id, + direction=meta["direction"], + ) + ] + + +def _detect_frost_alert(forecasts: list[Any], sensor_id: str) -> list[dict[str, Any]]: + violating = [forecast for forecast in forecasts[:3] if safe_number(getattr(forecast, "temperature_min", None), 10) < 0] + if not violating: + return [] + + first = violating[0] + coldest = min(safe_number(getattr(item, "temperature_min", None), 0) for item in violating) + duration_hours = max(len(violating) * 24.0, 24.0) + meta = METRIC_META["temperature"] + distance_ratio = min((meta["threshold"] - coldest) / meta["danger_span"], 1.0) + severity = _build_severity(distance_ratio, duration_hours) + timestamp = _timestamp_for(first, _now()) + return [ + _make_alert( + metric_type="temperature", + current_value=coldest, + threshold_value=meta["threshold"], + severity=severity, + duration_hours=duration_hours, + timestamp=timestamp, + sensor_id=sensor_id, + direction=meta["direction"], + metadata={"forecast_days_impacted": len(violating)}, + ) + ] + + +def _detect_fungal_risk(sensor: Any, forecasts: list[Any], history: list[Any], sensor_id: str) -> list[dict[str, Any]]: + humidity_values = [safe_number(getattr(forecast, "humidity_mean", None), None) for forecast in forecasts[:3]] + humidity_values = [value for value in humidity_values if value is not None] + if not humidity_values: + return [] + + humidity = sum(humidity_values) / len(humidity_values) + moisture = safe_number(getattr(sensor, "soil_moisture", None), 0) + meta = METRIC_META["fungal_risk"] + threshold = meta["threshold"] + if humidity <= threshold or moisture <= 60: + return [] + + timestamp = _timestamp_for(sensor, _now()) + duration_hours, started_at = _collect_active_history_duration( + current_value=moisture, + history=history, + field_name="soil_moisture", + threshold=60.0, + direction="above", + fallback_timestamp=timestamp, + ) + duration_hours = max(duration_hours, len(forecasts[:3]) * 12.0) + humidity_ratio = min((humidity - threshold) / meta["danger_span"], 1.0) + moisture_ratio = min((moisture - 60.0) / 20.0, 1.0) + severity = _build_severity((humidity_ratio * 0.6) + (moisture_ratio * 0.4), duration_hours) + return [ + _make_alert( + metric_type="fungal_risk", + current_value=humidity, + threshold_value=threshold, + severity=severity, + duration_hours=duration_hours, + timestamp=started_at, + sensor_id=sensor_id, + direction=meta["direction"], + metadata={"soil_moisture": round(moisture, 2)}, + ) + ] + + +def _sort_alerts(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + alerts, + key=lambda alert: ( + SEVERITY_ORDER[alert["severity"]], + alert["duration_hours"], + abs(float(alert["current_value"])) if isinstance(alert["current_value"], (int, float)) else 0, + ), + reverse=True, + ) + + +def _build_clusters(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for alert in alerts: + grouped[alert["domain"]].append(alert) + + clusters: list[dict[str, Any]] = [] + for domain, items in grouped.items(): + ordered = _sort_alerts(items) + top = ordered[0] + clusters.append( + { + "domain": domain, + "title": CLUSTER_TITLES.get(domain, domain), + "alert_count": len(items), + "highest_severity": top["severity"], + "primary_metric": top["metric_type"], + "summary": top["summary"], + "alert_ids": [f"{item['metric_type']}:{item['timestamp']}" for item in ordered], + } + ) + return sorted(clusters, key=lambda cluster: SEVERITY_ORDER[cluster["highest_severity"]], reverse=True) + + +def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]: + stats: list[dict[str, Any]] = [] + for metric_type, meta in METRIC_META.items(): + matches = [alert for alert in alerts if alert["metric_type"] == metric_type] + if not matches: + continue + top = _sort_alerts(matches)[0] + ui = SEVERITY_UI[top["severity"]] + stats.append( + { + "title": meta["title"], + "count": str(len(matches)), + "avatarColor": ui["avatarColor"], + "avatarIcon": meta["icon"], + "severity": top["severity"], + "topSummary": top["summary"], + } + ) + return stats + + +def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + forecasts = context.get("forecasts", []) + history = context.get("history", []) + + if sensor is None: + return { + "totalAlerts": 0, + "alerts": [], + "alertStats": [], + "alertClusters": [], + "mostCriticalIssue": None, + "prioritizedAlertSummaries": [], + "recommendedOperationalActions": [], + "humanReadableExplanations": [], + } + + alerts = [] + alerts.extend(_detect_moisture_alert(sensor, history, sensor_id)) + alerts.extend(_detect_ph_alert(sensor, history, sensor_id)) + alerts.extend(_detect_ec_alert(sensor, history, sensor_id)) + alerts.extend(_detect_frost_alert(forecasts, sensor_id)) + alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id)) + + ordered_alerts = _sort_alerts(alerts) + clusters = _build_clusters(ordered_alerts) + top_alert = ordered_alerts[0] if ordered_alerts else None + + return { + "totalAlerts": len(ordered_alerts), + "alerts": ordered_alerts, + "alertStats": _build_alert_stats(ordered_alerts), + "alertClusters": clusters, + "mostCriticalIssue": top_alert, + "prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts], + "recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts], + "humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts], + } diff --git a/Modules/Ai/farm_alerts/apps.py b/Modules/Ai/farm_alerts/apps.py new file mode 100644 index 0000000..4d111cf --- /dev/null +++ b/Modules/Ai/farm_alerts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmAlertsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farm_alerts" + verbose_name = "Farm Alerts" diff --git a/Modules/Ai/farm_alerts/migrations/0001_initial.py b/Modules/Ai/farm_alerts/migrations/0001_initial.py new file mode 100644 index 0000000..e4a53ed --- /dev/null +++ b/Modules/Ai/farm_alerts/migrations/0001_initial.py @@ -0,0 +1,38 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="FarmAlertNotification", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(db_index=True)), + ("endpoint", models.CharField(choices=[("tracker", "Tracker"), ("timeline", "Timeline")], db_index=True, max_length=32)), + ("level", models.CharField(choices=[("info", "اطلاع رسانی"), ("warning", "هشدار"), ("danger", "خطر")], db_index=True, max_length=16)), + ("title", models.CharField(max_length=255)), + ("message", models.TextField(blank=True)), + ("suggested_action", models.TextField(blank=True)), + ("source_alert_id", models.CharField(blank=True, db_index=True, max_length=255)), + ("source_metric_type", models.CharField(blank=True, db_index=True, max_length=64)), + ("fingerprint", models.CharField(max_length=64, unique=True)), + ("payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "farm_alerts_notification", + "ordering": ["-updated_at", "-created_at"], + "verbose_name": "Farm Alert Notification", + "verbose_name_plural": "Farm Alert Notifications", + "indexes": [ + models.Index(fields=["farm_uuid", "endpoint", "-updated_at"], name="farm_alerts_farm_ep_updated_idx"), + models.Index(fields=["farm_uuid", "level", "-updated_at"], name="farm_alerts_farm_level_updated_idx"), + ], + }, + ), + ] diff --git a/Modules/Ai/farm_alerts/migrations/__init__.py b/Modules/Ai/farm_alerts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_alerts/models.py b/Modules/Ai/farm_alerts/models.py new file mode 100644 index 0000000..a5a84a2 --- /dev/null +++ b/Modules/Ai/farm_alerts/models.py @@ -0,0 +1,45 @@ +from django.db import models + + +class FarmAlertNotification(models.Model): + LEVEL_INFO = "info" + LEVEL_WARNING = "warning" + LEVEL_DANGER = "danger" + LEVEL_CHOICES = [ + (LEVEL_INFO, "اطلاع رسانی"), + (LEVEL_WARNING, "هشدار"), + (LEVEL_DANGER, "خطر"), + ] + + ENDPOINT_TRACKER = "tracker" + ENDPOINT_TIMELINE = "timeline" + ENDPOINT_CHOICES = [ + (ENDPOINT_TRACKER, "Tracker"), + (ENDPOINT_TIMELINE, "Timeline"), + ] + + farm_uuid = models.UUIDField(db_index=True) + endpoint = models.CharField(max_length=32, choices=ENDPOINT_CHOICES, db_index=True) + level = models.CharField(max_length=16, choices=LEVEL_CHOICES, db_index=True) + title = models.CharField(max_length=255) + message = models.TextField(blank=True) + suggested_action = models.TextField(blank=True) + source_alert_id = models.CharField(max_length=255, blank=True, db_index=True) + source_metric_type = models.CharField(max_length=64, blank=True, db_index=True) + fingerprint = models.CharField(max_length=64, unique=True) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_alerts_notification" + ordering = ["-updated_at", "-created_at"] + indexes = [ + models.Index(fields=["farm_uuid", "endpoint", "-updated_at"]), + models.Index(fields=["farm_uuid", "level", "-updated_at"]), + ] + verbose_name = "Farm Alert Notification" + verbose_name_plural = "Farm Alert Notifications" + + def __str__(self): + return f"{self.farm_uuid} - {self.endpoint} - {self.level} - {self.title}" diff --git a/Modules/Ai/farm_alerts/serializers.py b/Modules/Ai/farm_alerts/serializers.py new file mode 100644 index 0000000..27c70a7 --- /dev/null +++ b/Modules/Ai/farm_alerts/serializers.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from .models import FarmAlertNotification + + +class IncomingAlertSerializer(serializers.Serializer): + alert_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه هشدار") + level = serializers.CharField(required=False, allow_blank=True, help_text="سطح هشدار") + title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان هشدار") + message = serializers.CharField(required=False, allow_blank=True, help_text="متن هشدار") + suggested_action = serializers.CharField(required=False, allow_blank=True, help_text="اقدام پیشنهادی") + source_metric_type = serializers.CharField(required=False, allow_blank=True, help_text="نوع شاخص") + timestamp = serializers.DateTimeField(required=False, allow_null=True, help_text="زمان هشدار") + payload = serializers.JSONField(required=False, help_text="داده تکمیلی هشدار") + + +class FarmAlertsRequestSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, help_text="شناسه مزرعه") + alerts = IncomingAlertSerializer( + many=True, + required=False, + help_text="لیست هشدارهای ورودی که باید در تحلیل RAG در نظر گرفته شوند", + ) + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + attrs["farm_uuid"] = farm_uuid + attrs["alerts"] = attrs.get("alerts") or [] + return attrs + + +class FarmAlertNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = FarmAlertNotification + fields = [ + "id", + "farm_uuid", + "endpoint", + "level", + "title", + "message", + "suggested_action", + "source_alert_id", + "source_metric_type", + "payload", + "created_at", + "updated_at", + ] + read_only_fields = fields diff --git a/Modules/Ai/farm_alerts/services.py b/Modules/Ai/farm_alerts/services.py new file mode 100644 index 0000000..c63c01d --- /dev/null +++ b/Modules/Ai/farm_alerts/services.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import hashlib +import json +import logging +from typing import Any + +from django.apps import apps +from django.core.serializers.json import DjangoJSONEncoder + +from farm_data.services import get_farm_details +from farm_data.context import load_farm_context +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +from .models import FarmAlertNotification +from .alerts_tracker import build_farm_alerts_tracker + +logger = logging.getLogger(__name__) + +KB_NAME = "farm_alerts" +SERVICE_ID = "farm_alerts" + +TRACKER_PROMPT = ( + "وضعیت هشدارهای مزرعه را فقط بر اساس داده های ساختاریافته، اطلاعات مزرعه، alertهاي ورودي، و متون بازیابی شده از پایگاه دانش تحلیل کن. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, status_level, notifications. " + "status_level فقط یکی از danger, warning, info باشد. " + "notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد. " + "سطوح level فقط یکی از danger, warning, info باشند. " + "اگر هشدار مهمی وجود ندارد، notifications را خالی برگردان." +) + +TIMELINE_PROMPT = ( + "بر اساس داده های هشدار مزرعه و alertهاي ورودي، یک timeline عملیاتی بساز. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, timeline, notifications. " + "timeline باید آرایه ای از آبجکت ها با کلیدهای timestamp, level, title, description, source_alert_id, source_metric_type باشد. " + "level فقط danger, warning, info باشد. " + "notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد." +) + + +def _json_dumps(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2, cls=DjangoJSONEncoder) + + +def _clean_json_response(raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + return {} + try: + return json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + logger.warning("Invalid JSON returned by farm_alerts LLM: %s", cleaned[:500]) + return {} + + +def _severity_to_level(severity: str) -> str: + normalized = (severity or "").strip().lower() + if normalized in {"critical", "high", "danger"}: + return FarmAlertNotification.LEVEL_DANGER + if normalized in {"medium", "warning"}: + return FarmAlertNotification.LEVEL_WARNING + return FarmAlertNotification.LEVEL_INFO + + +def _normalize_level(level: str | None) -> str: + normalized = (level or "").strip().lower() + if normalized in { + FarmAlertNotification.LEVEL_DANGER, + FarmAlertNotification.LEVEL_WARNING, + FarmAlertNotification.LEVEL_INFO, + }: + return normalized + if normalized in {"high", "critical"}: + return FarmAlertNotification.LEVEL_DANGER + if normalized in {"medium", "alert"}: + return FarmAlertNotification.LEVEL_WARNING + return FarmAlertNotification.LEVEL_INFO + + +def _alert_identifier(alert: dict[str, Any]) -> str: + metric_type = alert.get("metric_type", "alert") + timestamp = alert.get("timestamp", "") + return f"{metric_type}:{timestamp}" + + +def _forecast_summary(context: dict[str, Any]) -> list[dict[str, Any]]: + forecasts = context.get("forecasts", []) + return [ + { + "date": getattr(item, "forecast_date", None), + "temperature_min": getattr(item, "temperature_min", None), + "temperature_max": getattr(item, "temperature_max", None), + "humidity_mean": getattr(item, "humidity_mean", None), + "precipitation": getattr(item, "precipitation", None), + "et0": getattr(item, "et0", None), + } + for item in forecasts[:7] + ] + + +def _farm_profile(context: dict[str, Any], farm_uuid: str) -> dict[str, Any]: + sensor = context.get("sensor") + location = context.get("location") + plants = context.get("plants", []) + irrigation_method = getattr(sensor, "irrigation_method", None) if sensor else None + return { + "farm_uuid": farm_uuid, + "location": { + "latitude": float(location.latitude) if location else None, + "longitude": float(location.longitude) if location else None, + }, + "plant_names": [getattr(plant, "name", "") for plant in plants], + "irrigation_method": getattr(irrigation_method, "name", None), + "last_sensor_update": getattr(sensor, "updated_at", None), + } + + +def _normalize_incoming_alerts(alerts: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for item in alerts or []: + if not isinstance(item, dict): + continue + normalized.append( + { + "alert_id": item.get("alert_id") or None, + "level": item.get("level") or None, + "title": item.get("title") or None, + "message": item.get("message") or None, + "suggested_action": item.get("suggested_action") or None, + "source_metric_type": item.get("source_metric_type") or None, + "timestamp": item.get("timestamp"), + "payload": item.get("payload") if isinstance(item.get("payload"), dict) else {}, + } + ) + return normalized + + +def _build_structured_context( + farm_uuid: str, + incoming_alerts: list[dict[str, Any]] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + context = load_farm_context(farm_uuid) + if context is None: + raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.") + + tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None) + structured = { + "farm_profile": _farm_profile(context, farm_uuid), + "tracker": tracker, + "forecasts": _forecast_summary(context), + "incoming_alerts": _normalize_incoming_alerts(incoming_alerts), + } + return context, structured + + +def _validate_tracker_response(payload: dict[str, Any]) -> dict[str, Any]: + required_keys = {"headline", "overview", "status_level", "notifications"} + missing = [key for key in required_keys if key not in payload] + if missing: + raise ValueError( + "Farm alerts tracker response is missing required fields: " + + ", ".join(missing) + ) + if not isinstance(payload.get("notifications"), list): + raise ValueError("Farm alerts tracker notifications must be a list.") + return payload + + +def _validate_timeline_response(payload: dict[str, Any]) -> dict[str, Any]: + required_keys = {"headline", "overview", "timeline", "notifications"} + missing = [key for key in required_keys if key not in payload] + if missing: + raise ValueError( + "Farm alerts timeline response is missing required fields: " + + ", ".join(missing) + ) + if not isinstance(payload.get("timeline"), list): + raise ValueError("Farm alerts timeline must be a list.") + if not isinstance(payload.get("notifications"), list): + raise ValueError("Farm alerts timeline notifications must be a list.") + return payload + + +def _notification_fingerprint( + *, + farm_uuid: str, + endpoint: str, + level: str, + title: str, + source_alert_id: str, + source_metric_type: str, +) -> str: + raw = "|".join([ + str(farm_uuid), + endpoint, + level, + source_alert_id or "-", + source_metric_type or "-", + title.strip(), + ]) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _save_notifications( + *, + farm_uuid: str, + endpoint: str, + notifications: list[dict[str, Any]], +) -> list[FarmAlertNotification]: + saved: list[FarmAlertNotification] = [] + for item in notifications: + level = _normalize_level(item.get("level")) + title = (item.get("title") or "هشدار مزرعه").strip() + source_alert_id = (item.get("source_alert_id") or "").strip() + source_metric_type = (item.get("source_metric_type") or "").strip() + fingerprint = _notification_fingerprint( + farm_uuid=farm_uuid, + endpoint=endpoint, + level=level, + title=title, + source_alert_id=source_alert_id, + source_metric_type=source_metric_type, + ) + payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} + notification, _ = FarmAlertNotification.objects.update_or_create( + fingerprint=fingerprint, + defaults={ + "farm_uuid": farm_uuid, + "endpoint": endpoint, + "level": level, + "title": title, + "message": item.get("message") or "", + "suggested_action": item.get("suggested_action") or "", + "source_alert_id": source_alert_id, + "source_metric_type": source_metric_type, + "payload": payload, + }, + ) + saved.append(notification) + return saved + + +def _serialize_notification(notification: FarmAlertNotification) -> dict[str, Any]: + return { + "id": notification.id, + "farm_uuid": str(notification.farm_uuid), + "endpoint": notification.endpoint, + "level": notification.level, + "title": notification.title, + "message": notification.message, + "suggested_action": notification.suggested_action, + "source_alert_id": notification.source_alert_id, + "source_metric_type": notification.source_metric_type, + "payload": notification.payload, + "created_at": notification.created_at.isoformat(), + "updated_at": notification.updated_at.isoformat(), + } + + +def _build_service_config(cfg: RAGConfig, service_id: str) -> tuple[Any, Any, str, Any]: + service = get_service_config(service_id, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service.llm.model + return service, service_cfg, model, client + + +def _build_messages( + *, + prompt: str, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + structured_context: dict[str, Any], +) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(prompt) + system_parts.append("[کانتکست ساختاریافته هشدار مزرعه]\n" + _json_dumps(structured_context)) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + +def _llm_response( + *, + farm_uuid: str, + service_id: str, + prompt: str, + query: str, + structured_context: dict[str, Any], +) -> tuple[dict[str, Any], str, str]: + cfg = load_rag_config() + service, service_cfg, model, client = _build_service_config(cfg, service_id) + farm_details = get_farm_details(farm_uuid) + rag_context = build_rag_context( + query=query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=service_id, + farm_details=farm_details, + ) + system_prompt, messages = _build_messages( + prompt=prompt, + service=service, + cfg=cfg, + query=query, + rag_context=rag_context, + structured_context=structured_context, + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=service_id, + model=model, + query=query, + system_prompt=system_prompt, + messages=messages, + ) + try: + response = client.chat.completions.create(model=model, messages=messages) + raw = response.choices[0].message.content.strip() + parsed = _clean_json_response(raw) + if not parsed: + raise ValueError("farm_alerts LLM returned an empty or invalid JSON payload.") + _complete_audit_log(audit_log, raw) + return parsed, raw, service.tone_file or "" + except Exception as exc: + logger.error("farm_alerts llm error for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RuntimeError(f"Farm alerts generation failed for farm {farm_uuid}.") from exc + + +def get_farm_alerts_tracker( + *, + farm_uuid: str, + query: str | None = None, + alerts: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + _, structured_context = _build_structured_context(farm_uuid, incoming_alerts=alerts) + tracker = structured_context["tracker"] + user_query = query or "وضعیت فعلی هشدارهای مزرعه را ارزیابی کن و اگر لازم است notification بساز." + llm_result, raw_response, tone_file = _llm_response( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + prompt=TRACKER_PROMPT, + query=user_query, + structured_context=structured_context, + ) + llm_result = _validate_tracker_response(llm_result) + + notifications_input = llm_result["notifications"] + saved_notifications = _save_notifications( + farm_uuid=farm_uuid, + endpoint=FarmAlertNotification.ENDPOINT_TRACKER, + notifications=notifications_input, + ) + return { + "farm_uuid": farm_uuid, + "service_id": SERVICE_ID, + "tracker": tracker, + "headline": llm_result["headline"], + "overview": llm_result["overview"], + "status_level": _normalize_level(llm_result.get("status_level")), + "notifications": [_serialize_notification(item) for item in saved_notifications], + "raw_llm_response": raw_response or None, + "structured_context": structured_context, + } + + +def get_farm_alerts_timeline( + *, + farm_uuid: str, + query: str | None = None, + alerts: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + _, structured_context = _build_structured_context(farm_uuid, incoming_alerts=alerts) + tracker = structured_context["tracker"] + user_query = query or "برای هشدارهای مزرعه یک timeline عملیاتی بساز و اگر لازم است notification ثبت کن." + llm_result, raw_response, tone_file = _llm_response( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + prompt=TIMELINE_PROMPT, + query=user_query, + structured_context=structured_context, + ) + llm_result = _validate_timeline_response(llm_result) + + timeline = llm_result["timeline"] + notifications_input = llm_result["notifications"] + + saved_notifications = _save_notifications( + farm_uuid=farm_uuid, + endpoint=FarmAlertNotification.ENDPOINT_TIMELINE, + notifications=notifications_input, + ) + return { + "farm_uuid": farm_uuid, + "service_id": SERVICE_ID, + "tracker": tracker, + "headline": llm_result["headline"], + "overview": llm_result["overview"], + "timeline": timeline, + "notifications": [_serialize_notification(item) for item in saved_notifications], + "raw_llm_response": raw_response or None, + "structured_context": structured_context, + } diff --git a/Modules/Ai/farm_alerts/urls.py b/Modules/Ai/farm_alerts/urls.py new file mode 100644 index 0000000..f93c9b8 --- /dev/null +++ b/Modules/Ai/farm_alerts/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import FarmAlertsTrackerView + + +urlpatterns = [ + path("tracker/", FarmAlertsTrackerView.as_view(), name="farm-alerts-tracker"), +] diff --git a/Modules/Ai/farm_alerts/views.py b/Modules/Ai/farm_alerts/views.py new file mode 100644 index 0000000..cfc029d --- /dev/null +++ b/Modules/Ai/farm_alerts/views.py @@ -0,0 +1,76 @@ +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response + +from .serializers import FarmAlertsRequestSerializer +from .services import get_farm_alerts_tracker + + +FarmAlertsValidationErrorSerializer = build_envelope_serializer( + "FarmAlertsValidationErrorSerializer", + data_required=False, + allow_null=True, +) +FarmAlertsTrackerResponseSerializer = build_envelope_serializer( + "FarmAlertsTrackerResponseSerializer", + data_schema=None, +) + + +class FarmAlertsTrackerView(APIView): + @extend_schema( + tags=["Farm Alerts"], + summary="ارزیابی tracker هشدارهای مزرعه", + description=( + "با دریافت farm_uuid، هشدارهای مزرعه را تحلیل می کند، " + "کانتکست مزرعه و لیست alertهای ورودی را به RAG می فرستد، " + "و notificationهای سطح خطر/هشدار/اطلاع رسانی را در دیتابیس ذخیره می کند." + ), + request=FarmAlertsRequestSerializer, + responses={ + 200: build_response(FarmAlertsTrackerResponseSerializer, "خروجی tracker هشدارهای مزرعه."), + 400: build_response(FarmAlertsValidationErrorSerializer, "پارامتر ورودی نامعتبر است."), + 500: build_response(FarmAlertsValidationErrorSerializer, "خطا در تولید خروجی tracker هشدارها."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست tracker", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + } + ], + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = FarmAlertsRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + validated = serializer.validated_data + try: + result = get_farm_alerts_tracker( + farm_uuid=validated["farm_uuid"], + alerts=validated.get("alerts"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تولید tracker هشدارها: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK) diff --git a/Modules/Ai/farm_data/__init__.py b/Modules/Ai/farm_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_data/admin.py b/Modules/Ai/farm_data/admin.py new file mode 100644 index 0000000..bcaec56 --- /dev/null +++ b/Modules/Ai/farm_data/admin.py @@ -0,0 +1,48 @@ +from django.contrib import admin + +from .models import FarmPlantAssignment, ParameterUpdateLog, PlantCatalogSnapshot, SensorData, SensorParameter + + +@admin.register(SensorData) +class SensorDataAdmin(admin.ModelAdmin): + list_display = ( + "farm_uuid", + "center_location_id", + "weather_forecast_id", + "sensor_keys", + "updated_at", + ) + list_filter = ("updated_at",) + search_fields = ("farm_uuid", "center_location_id") + + @admin.display(description="sensor keys") + def sensor_keys(self, obj): + payload = obj.sensor_payload if isinstance(obj.sensor_payload, dict) else {} + return ", ".join(payload.keys()) + + +@admin.register(PlantCatalogSnapshot) +class PlantCatalogSnapshotAdmin(admin.ModelAdmin): + list_display = ("backend_plant_id", "name", "is_active", "source_updated_at", "updated_at") + search_fields = ("backend_plant_id", "name", "slug") + list_filter = ("is_active",) + + +@admin.register(FarmPlantAssignment) +class FarmPlantAssignmentAdmin(admin.ModelAdmin): + list_display = ("farm", "plant", "position", "stage", "updated_at") + search_fields = ("farm__farm_uuid", "plant__name") + list_filter = ("stage",) + + +@admin.register(SensorParameter) +class SensorParameterAdmin(admin.ModelAdmin): + list_display = ("sensor_key", "code", "name_fa", "unit", "data_type", "created_at") + search_fields = ("sensor_key", "code", "name_fa") + list_filter = ("sensor_key", "data_type") + + +@admin.register(ParameterUpdateLog) +class ParameterUpdateLogAdmin(admin.ModelAdmin): + list_display = ("parameter", "action", "updated_at") + list_filter = ("action", "updated_at") diff --git a/Modules/Ai/farm_data/apps.py b/Modules/Ai/farm_data/apps.py new file mode 100644 index 0000000..b418c39 --- /dev/null +++ b/Modules/Ai/farm_data/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FarmDataConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farm_data" + label = "sensor_data" + verbose_name = "farm-data" diff --git a/Modules/Ai/farm_data/context.py b/Modules/Ai/farm_data/context.py new file mode 100644 index 0000000..beaeb8a --- /dev/null +++ b/Modules/Ai/farm_data/context.py @@ -0,0 +1,34 @@ +from datetime import date + + +def load_farm_context(sensor_id: str) -> dict | None: + from irrigation.models import IrrigationMethod + from location_data.satellite_snapshot import build_location_block_satellite_snapshots + from farm_data.models import SensorData + from farm_data.services import get_farm_plant_snapshots + from weather.models import WeatherForecast + + try: + sensor = SensorData.objects.select_related("center_location").prefetch_related("plant_assignments__plant").get( + farm_uuid=sensor_id + ) + except SensorData.DoesNotExist: + return None + + location = sensor.center_location + satellite_snapshots = build_location_block_satellite_snapshots(location) + forecasts = list( + WeatherForecast.objects.filter(location=location, forecast_date__gte=date.today()).order_by("forecast_date")[:7] + ) + plants = get_farm_plant_snapshots(sensor) + irrigation_methods = list(IrrigationMethod.objects.all()[:5]) + + return { + "sensor": sensor, + "location": location, + "satellite_snapshots": satellite_snapshots, + "forecasts": forecasts, + "history": [], + "plants": plants, + "irrigation_methods": irrigation_methods, + } diff --git a/Modules/Ai/farm_data/management/__init__.py b/Modules/Ai/farm_data/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_data/management/commands/__init__.py b/Modules/Ai/farm_data/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_data/management/commands/seed_farm_data.py b/Modules/Ai/farm_data/management/commands/seed_farm_data.py new file mode 100644 index 0000000..8d02b66 --- /dev/null +++ b/Modules/Ai/farm_data/management/commands/seed_farm_data.py @@ -0,0 +1,74 @@ +""" +Management command to seed a fixed demo farm-data record. +Run: python manage.py seed_farm_data +""" +from uuid import UUID + +from django.core.management.base import BaseCommand + +from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import assign_farm_plants_from_backend_ids +from location_data.models import SoilLocation +from weather.models import WeatherForecast + + +DEMO_FARM_UUID = UUID("11111111-1111-1111-1111-111111111111") +DEMO_LATITUDE = "50.000000" +DEMO_LONGITUDE = "50.000000" +DEMO_SENSOR_PAYLOAD = { + "sensor-7-1": { + "soil_moisture": 42.3, + "soil_temperature": 21.4, + "soil_ph": 6.9, + "electrical_conductivity": 1.1, + "nitrogen": 28.0, + "phosphorus": 14.0, + "potassium": 19.0, + } +} +DEMO_PLANT_NAMES = [ + "گوجه‌فرنگی", + "خیار", +] + + +class Command(BaseCommand): + help = "Seed a fixed farm-data row with farm_uuid=11111111-1111-1111-1111-111111111111." + + def handle(self, *args, **options): + location, _ = SoilLocation.objects.get_or_create( + latitude=DEMO_LATITUDE, + longitude=DEMO_LONGITUDE, + ) + weather_forecast = ( + WeatherForecast.objects.filter(location=location) + .order_by("-forecast_date", "-id") + .first() + ) + + farm_data, created = SensorData.objects.update_or_create( + farm_uuid=DEMO_FARM_UUID, + defaults={ + "center_location": location, + "weather_forecast": weather_forecast, + "sensor_payload": DEMO_SENSOR_PAYLOAD, + }, + ) + plants = list( + PlantCatalogSnapshot.objects.filter(name__in=DEMO_PLANT_NAMES).order_by("name") + ) + if plants: + assign_farm_plants_from_backend_ids( + farm_data, + [plant.backend_plant_id for plant in plants], + ) + + status_text = "Created" if created else "Updated" + weather_text = weather_forecast.id if weather_forecast else "None" + plant_count = len(plants) + self.stdout.write( + self.style.SUCCESS( + f"{status_text} farm-data {farm_data.farm_uuid} for center_location_id={location.id} weather_forecast_id={weather_text} plants={plant_count}" + ) + ) + self.stdout.write(self.style.SUCCESS("\nDone seeding farm_data demo record.")) diff --git a/Modules/Ai/farm_data/management/commands/seed_sensor_parameters.py b/Modules/Ai/farm_data/management/commands/seed_sensor_parameters.py new file mode 100644 index 0000000..a343890 --- /dev/null +++ b/Modules/Ai/farm_data/management/commands/seed_sensor_parameters.py @@ -0,0 +1,70 @@ +""" +Management command to seed the 7 initial sensor parameters. +Run: python manage.py seed_sensor_parameters +""" +from django.core.management.base import BaseCommand + +from farm_data.models import ( + DEFAULT_SENSOR_DATA_TYPE, + DEFAULT_SENSOR_KEY, + ParameterUpdateLog, + SensorParameter, +) + + +INITIAL_PARAMETERS = [ + ("soil_moisture", "رطوبت خاک", "%"), + ("soil_temperature", "دما خاک", "°C"), + ("soil_ph", "pH خاک", ""), + ("electrical_conductivity", "هدایت الکتریکی", "dS/m"), + ("nitrogen", "ازت (N)", "mg/kg"), + ("phosphorus", "فسفر", "mg/kg"), + ("potassium", "پتاسیم", "mg/kg"), +] + + +class Command(BaseCommand): + help = "Seed 7 initial sensor parameters (soil_moisture, soil_temperature, etc.)" + + def add_arguments(self, parser): + parser.add_argument( + "--sensor-key", + default=DEFAULT_SENSOR_KEY, + help='کلید سنسور مثل "sensor-7-1" یا "leaf-sensor"', + ) + + def handle(self, *args, **options): + sensor_key = options["sensor_key"] + created_count = 0 + for code, name_fa, unit in INITIAL_PARAMETERS: + param, created = SensorParameter.objects.get_or_create( + sensor_key=sensor_key, + code=code, + defaults={ + "name_fa": name_fa, + "unit": unit, + "data_type": DEFAULT_SENSOR_DATA_TYPE, + }, + ) + if created: + ParameterUpdateLog.objects.create( + parameter=param, + action="added", + payload={ + "sensor_key": sensor_key, + "code": code, + "name_fa": name_fa, + "unit": unit, + }, + ) + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f" Created: {sensor_key}.{code} ({name_fa})" + ) + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new parameters for {sensor_key}." + ) + ) diff --git a/Modules/Ai/farm_data/migrations/0001_initial.py b/Modules/Ai/farm_data/migrations/0001_initial.py new file mode 100644 index 0000000..b0334f1 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# Generated by Django 5.2.11 on 2026-02-27 09:47 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('location_data', '0002_soildepthdata_refactor'), + ] + + operations = [ + migrations.CreateModel( + name='SensorDataHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid_sensor', models.UUIDField(help_text='شناسه سنسور')), + ('location_id', models.IntegerField(help_text='location_id از location_data')), + ('soil_moisture', models.FloatField(blank=True, null=True)), + ('soil_temperature', models.FloatField(blank=True, null=True)), + ('soil_ph', models.FloatField(blank=True, null=True)), + ('electrical_conductivity', models.FloatField(blank=True, null=True)), + ('nitrogen', models.FloatField(blank=True, null=True)), + ('phosphorus', models.FloatField(blank=True, null=True)), + ('potassium', models.FloatField(blank=True, null=True)), + ('recorded_at', models.DateTimeField(auto_now_add=True, help_text='زمان ثبت در تاریخچه')), + ], + options={ + 'verbose_name': 'تاریخچه داده سنسور', + 'verbose_name_plural': 'تاریخچه داده\u200cهای سنسور', + 'ordering': ['-recorded_at'], + }, + ), + migrations.CreateModel( + name='SensorParameter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(db_index=True, help_text='کد یکتا (مثلاً soil_moisture)', max_length=64, unique=True)), + ('name_fa', models.CharField(help_text='نام فارسی', max_length=128)), + ('unit', models.CharField(blank=True, help_text='واحد اندازه\u200cگیری', max_length=32)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'پارامتر سنسور', + 'verbose_name_plural': 'پارامترهای سنسور', + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='SensorData', + fields=[ + ('uuid_sensor', models.UUIDField(default=uuid.uuid4, editable=False, help_text='شناسه یکتای سنسور', primary_key=True, serialize=False)), + ('soil_moisture', models.FloatField(blank=True, help_text='رطوبت خاک', null=True)), + ('soil_temperature', models.FloatField(blank=True, help_text='دما خاک', null=True)), + ('soil_ph', models.FloatField(blank=True, help_text='pH خاک', null=True)), + ('electrical_conductivity', models.FloatField(blank=True, help_text='هدایت الکتریکی', null=True)), + ('nitrogen', models.FloatField(blank=True, help_text='ازت (N)', null=True)), + ('phosphorus', models.FloatField(blank=True, help_text='فسفر', null=True)), + ('potassium', models.FloatField(blank=True, help_text='پتاسیم', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('location', models.ForeignKey(db_column='location_id', help_text='همان location_id در location_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='location_data.soillocation')), + ], + options={ + 'verbose_name': 'داده سنسور', + 'verbose_name_plural': 'داده\u200cهای سنسور', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='ParameterUpdateLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('added', 'اضافه شده'), ('modified', 'ویرایش شده')], max_length=16)), + ('updated_at', models.DateTimeField(auto_now_add=True)), + ('parameter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='update_logs', to='sensor_data.sensorparameter')), + ], + options={ + 'verbose_name': 'لاگ آپدیت پارامتر', + 'verbose_name_plural': 'لاگ آپدیت پارامترها', + 'ordering': ['-updated_at'], + }, + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0002_seed_initial_parameters.py b/Modules/Ai/farm_data/migrations/0002_seed_initial_parameters.py new file mode 100644 index 0000000..ec952e7 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0002_seed_initial_parameters.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.11 on 2026-02-27 09:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sensor_data', '0001_initial'), + ] + + operations = [ + ] diff --git a/Modules/Ai/farm_data/migrations/0003_sensordata_plants.py b/Modules/Ai/farm_data/migrations/0003_sensordata_plants.py new file mode 100644 index 0000000..f6535b4 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0003_sensordata_plants.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plant', '0001_initial'), + ('sensor_data', '0002_seed_initial_parameters'), + ] + + operations = [ + migrations.AddField( + model_name='sensordata', + name='plants', + field=models.ManyToManyField(blank=True, help_text='گیاهان مرتبط با این سنسور', related_name='sensor_data', to='plant.plant'), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0004_alter_sensordata_location.py b/Modules/Ai/farm_data/migrations/0004_alter_sensordata_location.py new file mode 100644 index 0000000..628af8b --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0004_alter_sensordata_location.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.15 on 2026-03-27 08:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('location_data', '0005_merge_20260327_0840'), + ('sensor_data', '0003_sensordata_plants'), + ] + + operations = [ + migrations.AlterField( + model_name='sensordata', + name='location', + field=models.ForeignKey(db_column='location_id', help_text='همان location_id از location_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='location_data.soillocation'), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0005_delete_sensordatahistory.py b/Modules/Ai/farm_data/migrations/0005_delete_sensordatahistory.py new file mode 100644 index 0000000..c9b05d5 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0005_delete_sensordatahistory.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0004_alter_sensordata_location"), + ] + + operations = [ + migrations.DeleteModel( + name="SensorDataHistory", + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py b/Modules/Ai/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py new file mode 100644 index 0000000..c02d270 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0006_sensor_payload_and_dynamic_parameters.py @@ -0,0 +1,139 @@ +from django.db import migrations, models + + +DEFAULT_SENSOR_KEY = "sensor-7-1" + + +def migrate_sensor_fields_to_payload(apps, schema_editor): + SensorData = apps.get_model("sensor_data", "SensorData") + field_names = [ + "soil_moisture", + "soil_temperature", + "soil_ph", + "electrical_conductivity", + "nitrogen", + "phosphorus", + "potassium", + ] + + for sensor in SensorData.objects.all().iterator(): + values = {} + for field_name in field_names: + value = getattr(sensor, field_name, None) + if value is not None: + values[field_name] = value + + sensor.sensor_payload = {DEFAULT_SENSOR_KEY: values} if values else {} + sensor.save(update_fields=["sensor_payload"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0005_delete_sensordatahistory"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="sensor_payload", + field=models.JSONField( + blank=True, + default=dict, + help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}', + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="sensor_key", + field=models.CharField( + db_index=True, + default=DEFAULT_SENSOR_KEY, + help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"', + max_length=64, + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="data_type", + field=models.CharField( + default="float", + help_text="نوع داده پارامتر مثل float, int, string, bool", + max_length=32, + ), + ), + migrations.AddField( + model_name="sensorparameter", + name="metadata", + field=models.JSONField( + blank=True, + default=dict, + help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI", + ), + ), + migrations.AddField( + model_name="parameterupdatelog", + name="payload", + field=models.JSONField( + blank=True, + default=dict, + help_text="خلاصه تغییرات پارامتر برای audit", + ), + ), + migrations.RunPython( + migrate_sensor_fields_to_payload, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_moisture", + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_temperature", + ), + migrations.RemoveField( + model_name="sensordata", + name="soil_ph", + ), + migrations.RemoveField( + model_name="sensordata", + name="electrical_conductivity", + ), + migrations.RemoveField( + model_name="sensordata", + name="nitrogen", + ), + migrations.RemoveField( + model_name="sensordata", + name="phosphorus", + ), + migrations.RemoveField( + model_name="sensordata", + name="potassium", + ), + migrations.AlterField( + model_name="sensorparameter", + name="code", + field=models.CharField( + db_index=True, + help_text="کد پارامتر (مثلاً soil_moisture)", + max_length=64, + ), + ), + migrations.AlterModelOptions( + name="sensorparameter", + options={ + "ordering": ["sensor_key", "code"], + "verbose_name": "پارامتر سنسور", + "verbose_name_plural": "پارامترهای سنسور", + }, + ), + migrations.AddConstraint( + model_name="sensorparameter", + constraint=models.UniqueConstraint( + fields=("sensor_key", "code"), + name="sensor_parameter_unique_sensor_code", + ), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py b/Modules/Ai/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py new file mode 100644 index 0000000..28d2e82 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0007_rename_uuid_sensor_to_farm_uuid.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0006_sensor_payload_and_dynamic_parameters"), + ] + + operations = [ + migrations.RenameField( + model_name="sensordata", + old_name="uuid_sensor", + new_name="farm_uuid", + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0008_rename_location_to_center_location.py b/Modules/Ai/farm_data/migrations/0008_rename_location_to_center_location.py new file mode 100644 index 0000000..8f05c48 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0008_rename_location_to_center_location.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0005_merge_20260327_0840"), + ("sensor_data", "0007_rename_uuid_sensor_to_farm_uuid"), + ] + + operations = [ + migrations.RenameField( + model_name="sensordata", + old_name="location", + new_name="center_location", + ), + migrations.AlterField( + model_name="sensordata", + name="center_location", + field=models.ForeignKey( + db_column="center_location_id", + help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation", + on_delete=django.db.models.deletion.CASCADE, + related_name="farm_data", + to="location_data.soillocation", + ), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py b/Modules/Ai/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py new file mode 100644 index 0000000..da6ab48 --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0009_add_weather_forecast_to_sensordata.py @@ -0,0 +1,45 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def link_latest_weather_forecast(apps, schema_editor): + SensorData = apps.get_model("sensor_data", "SensorData") + WeatherForecast = apps.get_model("weather", "WeatherForecast") + + for farm_data in SensorData.objects.all().iterator(): + forecast = ( + WeatherForecast.objects.filter(location_id=farm_data.center_location_id) + .order_by("-forecast_date", "-id") + .first() + ) + if forecast: + farm_data.weather_forecast_id = forecast.id + farm_data.save(update_fields=["weather_forecast"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0008_rename_location_to_center_location"), + ("weather", "0003_seed_weather_forecasts"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="weather_forecast", + field=models.ForeignKey( + blank=True, + db_column="weather_forecast_id", + help_text="رکورد آب وهوای مرتبط با مرکز زمین", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="farm_data_entries", + to="weather.weatherforecast", + ), + ), + migrations.RunPython( + link_latest_weather_forecast, + migrations.RunPython.noop, + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0010_rename_tables_to_farm_data.py b/Modules/Ai/farm_data/migrations/0010_rename_tables_to_farm_data.py new file mode 100644 index 0000000..d480f6d --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0010_rename_tables_to_farm_data.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0009_add_weather_forecast_to_sensordata"), + ] + + operations = [ + migrations.AlterField( + model_name="sensordata", + name="farm_uuid", + field=models.UUIDField( + editable=False, + help_text="شناسه یکتای farm که از API دریافت می‌شود", + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="sensordata", + name="plants", + field=models.ManyToManyField( + blank=True, + db_table="farm_data_sensordata_plants", + help_text="گیاهان مرتبط با این farm", + related_name="farm_data", + to="plant.plant", + ), + ), + migrations.AlterModelTable( + name="sensordata", + table="farm_data_sensordata", + ), + migrations.AlterModelTable( + name="sensorparameter", + table="farm_data_sensorparameter", + ), + migrations.AlterModelTable( + name="parameterupdatelog", + table="farm_data_parameterupdatelog", + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0011_sensordata_irrigation_method.py b/Modules/Ai/farm_data/migrations/0011_sensordata_irrigation_method.py new file mode 100644 index 0000000..e6ddd0f --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0011_sensordata_irrigation_method.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("irrigation", "0001_initial"), + ("sensor_data", "0010_rename_tables_to_farm_data"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="irrigation_method", + field=models.ForeignKey( + blank=True, + db_column="irrigation_method_id", + help_text="روش آبیاری انتخاب‌شده برای این farm", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="farm_data", + to="irrigation.irrigationmethod", + ), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py b/Modules/Ai/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py new file mode 100644 index 0000000..7f33a4d --- /dev/null +++ b/Modules/Ai/farm_data/migrations/0012_plant_catalog_snapshot_and_assignment.py @@ -0,0 +1,70 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensor_data", "0011_sensordata_irrigation_method"), + ] + + operations = [ + migrations.CreateModel( + name="PlantCatalogSnapshot", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("backend_plant_id", models.PositiveIntegerField(db_index=True, help_text="شناسه گیاه در Backend/plants", unique=True)), + ("name", models.CharField(db_index=True, max_length=255)), + ("slug", models.SlugField(blank=True, default="", max_length=255)), + ("icon", models.CharField(blank=True, default="leaf", max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("light", models.CharField(blank=True, default="", max_length=255)), + ("watering", models.CharField(blank=True, default="", max_length=255)), + ("soil", models.CharField(blank=True, default="", max_length=255)), + ("temperature", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("growth_stages", models.JSONField(blank=True, default=list)), + ("planting_season", models.CharField(blank=True, default="", max_length=255)), + ("harvest_time", models.CharField(blank=True, default="", max_length=255)), + ("spacing", models.CharField(blank=True, default="", max_length=255)), + ("fertilizer", models.CharField(blank=True, default="", max_length=255)), + ("health_profile", models.JSONField(blank=True, default=dict)), + ("irrigation_profile", models.JSONField(blank=True, default=dict)), + ("growth_profile", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("source_updated_at", models.DateTimeField(blank=True, help_text="updated_at رکورد canonical در Backend", null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "plant catalog snapshot", + "verbose_name_plural": "plant catalog snapshots", + "db_table": "farm_data_plantcatalogsnapshot", + "ordering": ["name", "backend_plant_id"], + }, + ), + migrations.CreateModel( + name="FarmPlantAssignment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("position", models.PositiveIntegerField(default=0)), + ("stage", models.CharField(blank=True, default="", max_length=64)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("assigned_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm", models.ForeignKey(db_column="farm_uuid", on_delete=django.db.models.deletion.CASCADE, related_name="plant_assignments", to="sensor_data.sensordata")), + ("plant", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_assignments", to="sensor_data.plantcatalogsnapshot")), + ], + options={ + "verbose_name": "farm plant assignment", + "verbose_name_plural": "farm plant assignments", + "db_table": "farm_data_farmplantassignment", + "ordering": ["position", "id"], + }, + ), + migrations.AddConstraint( + model_name="farmplantassignment", + constraint=models.UniqueConstraint(fields=("farm", "plant"), name="farm_data_unique_farm_plant_assignment"), + ), + ] diff --git a/Modules/Ai/farm_data/migrations/__init__.py b/Modules/Ai/farm_data/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/farm_data/models.py b/Modules/Ai/farm_data/models.py new file mode 100644 index 0000000..d41ab65 --- /dev/null +++ b/Modules/Ai/farm_data/models.py @@ -0,0 +1,337 @@ +from django.db import models + + +DEFAULT_SENSOR_KEY = "sensor-7-1" +DEFAULT_SENSOR_DATA_TYPE = "float" + + +class SensorPayloadMixin: + """دسترسی سازگار به مقادیر سنسور از payload پویا.""" + + sensor_payload: dict + + def _payload(self) -> dict: + if isinstance(self.sensor_payload, dict): + return self.sensor_payload + return {} + + def get_sensor_block(self, sensor_key: str | None = None) -> dict: + payload = self._payload() + if sensor_key: + block = payload.get(sensor_key, {}) + return block if isinstance(block, dict) else {} + + for _sensor_key, block in self.iter_sensor_blocks(): + return block + return {} + + def iter_sensor_blocks(self): + for sensor_key, block in self._payload().items(): + if isinstance(block, dict): + yield sensor_key, block + + def get_metric(self, metric_name: str, sensor_key: str | None = None): + block = self.get_sensor_block(sensor_key) + if metric_name in block: + return block.get(metric_name) + + for _candidate_key, candidate in self.iter_sensor_blocks(): + if metric_name in candidate: + return candidate.get(metric_name) + return None + + def get_sensor_keys(self) -> list[str]: + return [sensor_key for sensor_key, _block in self.iter_sensor_blocks()] + + def get_all_metrics(self) -> dict[str, dict]: + return { + sensor_key: dict(block) + for sensor_key, block in self.iter_sensor_blocks() + } + + @property + def soil_moisture(self): + return self.get_metric("soil_moisture") + + @property + def soil_temperature(self): + return self.get_metric("soil_temperature") + + @property + def soil_ph(self): + return self.get_metric("soil_ph") + + @property + def electrical_conductivity(self): + return self.get_metric("electrical_conductivity") + + @property + def nitrogen(self): + return self.get_metric("nitrogen") + + @property + def phosphorus(self): + return self.get_metric("phosphorus") + + @property + def potassium(self): + return self.get_metric("potassium") + + +class SensorData(SensorPayloadMixin, models.Model): + """ + داده‌های مزرعه/سنسور برای مرکز زمین. + مقادیر سنسورها به‌صورت JSON ذخیره می‌شوند تا بتوان چند نوع سنسور + و پارامترهای دلخواه را در یک رکورد نگه داشت. + نمونه: + { + "sensor-7-1": { + "soil_moisture": 22.4, + "soil_temperature": 18.1 + }, + "leaf-sensor": { + "leaf_wetness": 11 + } + } + """ + + farm_uuid = models.UUIDField( + primary_key=True, + editable=False, + help_text="شناسه یکتای farm که از API دریافت می‌شود", + ) + center_location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="farm_data", + db_column="center_location_id", + help_text="مرکز زمین مرتبط از جدول location_data.SoilLocation", + ) + weather_forecast = models.ForeignKey( + "weather.WeatherForecast", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="farm_data_entries", + db_column="weather_forecast_id", + help_text="رکورد آب وهوای مرتبط با مرکز زمین", + ) + sensor_payload = models.JSONField( + default=dict, + blank=True, + help_text='اطلاعات سنسورها در فرمت {"sensor-7-1": {...}}', + ) + plants = models.ManyToManyField( + "plant.Plant", + blank=True, + db_table="farm_data_sensordata_plants", + related_name="farm_data", + help_text="مسیر legacy برای گیاهان farm. برای خواندن canonical از plant_assignments/plant_snapshots استفاده شود.", + ) + irrigation_method = models.ForeignKey( + "irrigation.IrrigationMethod", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="farm_data", + db_column="irrigation_method_id", + help_text="روش آبیاری انتخاب‌شده برای این farm", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_sensordata" + ordering = ["-updated_at"] + verbose_name = "farm-data" + verbose_name_plural = "farm-data" + + def __str__(self): + return ( + f"SensorData({self.farm_uuid}, center_location={self.center_location_id}, " + f"weather_forecast={self.weather_forecast_id})" + ) + + @property + def location(self): + return self.center_location + + @location.setter + def location(self, value): + self.center_location = value + + @property + def location_id(self): + return self.center_location_id + + @property + def plant_snapshots(self): + return [assignment.plant for assignment in self.plant_assignments.select_related("plant").order_by("position", "id")] + + +class PlantCatalogSnapshot(models.Model): + """ + کپی خواندنی از کاتالوگ گیاه Backend برای مصرف ماژول‌های AI. + """ + + backend_plant_id = models.PositiveIntegerField( + unique=True, + db_index=True, + help_text="شناسه گیاه در Backend/plants", + ) + name = models.CharField(max_length=255, db_index=True) + slug = models.SlugField(max_length=255, blank=True, default="") + icon = models.CharField(max_length=255, blank=True, default="leaf") + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + light = models.CharField(max_length=255, blank=True, default="") + watering = models.CharField(max_length=255, blank=True, default="") + soil = models.CharField(max_length=255, blank=True, default="") + temperature = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + growth_stages = models.JSONField(blank=True, default=list) + planting_season = models.CharField(max_length=255, blank=True, default="") + harvest_time = models.CharField(max_length=255, blank=True, default="") + spacing = models.CharField(max_length=255, blank=True, default="") + fertilizer = models.CharField(max_length=255, blank=True, default="") + health_profile = models.JSONField(default=dict, blank=True) + irrigation_profile = models.JSONField(default=dict, blank=True) + growth_profile = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + source_updated_at = models.DateTimeField( + null=True, + blank=True, + help_text="updated_at رکورد canonical در Backend", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_plantcatalogsnapshot" + ordering = ["name", "backend_plant_id"] + verbose_name = "plant catalog snapshot" + verbose_name_plural = "plant catalog snapshots" + + def __str__(self): + return f"{self.name} ({self.backend_plant_id})" + + +class FarmPlantAssignment(models.Model): + """ + رابطه مزرعه با snapshot گیاه برای read-model هوش مصنوعی. + """ + + farm = models.ForeignKey( + SensorData, + on_delete=models.CASCADE, + related_name="plant_assignments", + db_column="farm_uuid", + ) + plant = models.ForeignKey( + PlantCatalogSnapshot, + on_delete=models.CASCADE, + related_name="farm_assignments", + ) + position = models.PositiveIntegerField(default=0) + stage = models.CharField(max_length=64, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + assigned_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_data_farmplantassignment" + ordering = ["position", "id"] + constraints = [ + models.UniqueConstraint( + fields=["farm", "plant"], + name="farm_data_unique_farm_plant_assignment", + ) + ] + verbose_name = "farm plant assignment" + verbose_name_plural = "farm plant assignments" + + def __str__(self): + return f"{self.farm_id} -> {self.plant_id}" + + +class SensorParameter(models.Model): + """ + تعریف پارامترهای سنسور برای هر نوع سنسور. + با این ساختار می‌توان برای sensor-7-1 یا هر سنسور جدید، + پارامترهای اختصاصی تعریف کرد. + """ + + sensor_key = models.CharField( + max_length=64, + db_index=True, + default=DEFAULT_SENSOR_KEY, + help_text='کلید سنسور داخل JSON مثل "sensor-7-1" یا "leaf-sensor"', + ) + code = models.CharField( + max_length=64, + db_index=True, + help_text="کد پارامتر (مثلاً soil_moisture)", + ) + name_fa = models.CharField(max_length=128, help_text="نام فارسی") + unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری") + data_type = models.CharField( + max_length=32, + default=DEFAULT_SENSOR_DATA_TYPE, + help_text="نوع داده پارامتر مثل float, int, string, bool", + ) + metadata = models.JSONField( + default=dict, + blank=True, + help_text="اطلاعات تکمیلی پارامتر مثل بازه مجاز، توضیح یا تنظیمات UI", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_data_sensorparameter" + ordering = ["sensor_key", "code"] + constraints = [ + models.UniqueConstraint( + fields=["sensor_key", "code"], + name="sensor_parameter_unique_sensor_code", + ) + ] + verbose_name = "پارامتر سنسور" + verbose_name_plural = "پارامترهای سنسور" + + def __str__(self): + return f"{self.sensor_key}.{self.code} ({self.name_fa})" + + +class ParameterUpdateLog(models.Model): + """ + لاگ آپدیت لیست پارامترها. + """ + + ACTION_ADDED = "added" + ACTION_MODIFIED = "modified" + ACTION_CHOICES = [ + (ACTION_ADDED, "اضافه شده"), + (ACTION_MODIFIED, "ویرایش شده"), + ] + + parameter = models.ForeignKey( + SensorParameter, + on_delete=models.CASCADE, + related_name="update_logs", + ) + action = models.CharField(max_length=16, choices=ACTION_CHOICES) + payload = models.JSONField( + default=dict, + blank=True, + help_text="خلاصه تغییرات پارامتر برای audit", + ) + updated_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_data_parameterupdatelog" + ordering = ["-updated_at"] + verbose_name = "لاگ آپدیت پارامتر" + verbose_name_plural = "لاگ آپدیت پارامترها" + + def __str__(self): + return f"{self.parameter.code} - {self.action} - {self.updated_at}" diff --git a/Modules/Ai/farm_data/postman/farm_data.json b/Modules/Ai/farm_data/postman/farm_data.json new file mode 100644 index 0000000..2f0f7e3 --- /dev/null +++ b/Modules/Ai/farm_data/postman/farm_data.json @@ -0,0 +1,53 @@ +{ + "info": { + "name": "Farm Data", + "description": "API داده‌های farm: ایجاد/آپدیت رکورد farm و مدیریت پارامترهای سنسور", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8020"}, + {"key": "farm_uuid", "value": "00000000-0000-0000-0000-000000000000"} + ], + "item": [ + { + "name": "Upsert Farm Data (POST)", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Accept", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"farm_uuid\": \"{{farm_uuid}}\",\n \"farm_boundary\": {\n \"corners\": [\n {\"lat\": 35.7000, \"lon\": 51.3900},\n {\"lat\": 35.7000, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.4100},\n {\"lat\": 35.7200, \"lon\": 51.3900}\n ]\n },\n \"sensor_payload\": {\n \"sensor-7-1\": {\n \"soil_moisture\": 25.5,\n \"soil_temperature\": 22.3,\n \"soil_ph\": 7.2,\n \"electrical_conductivity\": 1.8,\n \"nitrogen\": 120.0,\n \"phosphorus\": 45.0,\n \"potassium\": 180.0\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/farm-data/", + "host": ["{{baseUrl}}"], + "path": ["api", "farm-data", ""] + } + }, + "description": "ایجاد یا آپدیت داده farm. مختصات گوشه‌های زمین را می‌گیرد، مرکز را خودش محاسبه می‌کند، location را می‌سازد و weather را از همان location پیدا می‌کند." + }, + { + "name": "Add Parameter", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Accept", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"sensor_key\": \"sensor-7-1\",\n \"code\": \"soil_moisture\",\n \"name_fa\": \"رطوبت خاک\",\n \"unit\": \"%\",\n \"data_type\": \"float\",\n \"metadata\": {\n \"min\": 0,\n \"max\": 100\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/farm-data/parameters/", + "host": ["{{baseUrl}}"], + "path": ["api", "farm-data", "parameters", ""] + } + }, + "description": "اضافه کردن یا ویرایش پارامتر جدید. در ParameterUpdateLog ثبت می‌شود." + } + ] +} diff --git a/Modules/Ai/farm_data/serializers.py b/Modules/Ai/farm_data/serializers.py new file mode 100644 index 0000000..1ad3010 --- /dev/null +++ b/Modules/Ai/farm_data/serializers.py @@ -0,0 +1,243 @@ +from rest_framework import serializers + +from irrigation.models import IrrigationMethod +from irrigation.serializers import IrrigationMethodSerializer +from weather.models import WeatherForecast + +from .models import ( + DEFAULT_SENSOR_DATA_TYPE, + DEFAULT_SENSOR_KEY, + FarmPlantAssignment, + PlantCatalogSnapshot, + SensorData, +) + + +class SensorDataUpdateSerializer(serializers.Serializer): + """ورودی آپدیت داده سنسور در ساختار JSON.""" + + farm_uuid = serializers.UUIDField(required=True) + farm_boundary = serializers.JSONField(required=True) + block_count = serializers.IntegerField(required=False, min_value=1, default=1) + sensor_key = serializers.CharField(required=False, default=DEFAULT_SENSOR_KEY) + sensor_payload = serializers.JSONField(required=False) + plant_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="لیست شناسه گیاهان canonical در Backend/plants", + ) + irrigation_method_id = serializers.IntegerField( + required=False, + allow_null=True, + help_text="شناسه روش آبیاری مرتبط", + ) + + def to_internal_value(self, data): + if not isinstance(data, dict): + raise serializers.ValidationError("بدنه درخواست باید JSON object باشد.") + + payload = dict(data) + known_fields = { + "farm_uuid", + "farm_boundary", + "block_count", + "sensor_key", + "sensor_payload", + "plant_ids", + "irrigation_method_id", + } + flat_metrics = { + key: value + for key, value in payload.items() + if key not in known_fields + } + + if flat_metrics: + sensor_key = payload.get("sensor_key", DEFAULT_SENSOR_KEY) + nested_payload = payload.get("sensor_payload") or {} + if nested_payload and not isinstance(nested_payload, dict): + raise serializers.ValidationError( + {"sensor_payload": "sensor_payload باید object باشد."} + ) + merged_payload = dict(nested_payload) + current_sensor_payload = merged_payload.get(sensor_key, {}) + if current_sensor_payload and not isinstance(current_sensor_payload, dict): + raise serializers.ValidationError( + {"sensor_payload": f"مقدار {sensor_key} باید object باشد."} + ) + merged_sensor_payload = dict(current_sensor_payload) + merged_sensor_payload.update(flat_metrics) + merged_payload[sensor_key] = merged_sensor_payload + payload["sensor_payload"] = merged_payload + + for key in flat_metrics: + payload.pop(key, None) + + return super().to_internal_value(payload) + + def validate_sensor_payload(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("sensor_payload باید object باشد.") + for sensor_key, sensor_values in value.items(): + if not isinstance(sensor_values, dict): + raise serializers.ValidationError( + f"مقدار سنسور {sensor_key} باید object باشد." + ) + return value + + def validate_irrigation_method_id(self, value): + if value is None: + return value + if not IrrigationMethod.objects.filter(pk=value).exists(): + raise serializers.ValidationError("روش آبیاری معتبر نیست.") + return value + + def validate(self, attrs): + if ( + "sensor_payload" not in attrs + and "plant_ids" not in attrs + and "irrigation_method_id" not in attrs + ): + raise serializers.ValidationError( + "حداقل یکی از sensor_payload یا plant_ids یا irrigation_method_id باید ارسال شود." + ) + return attrs + + +class SensorDataResponseSerializer(serializers.ModelSerializer): + """سریالایزر خروجی برای SensorData.""" + + plant_ids = serializers.SerializerMethodField() + irrigation_method_id = serializers.IntegerField( + source="irrigation_method.id", + read_only=True, + allow_null=True, + ) + + def get_plant_ids(self, obj): + return [plant.backend_plant_id for plant in obj.plant_snapshots] + + class Meta: + model = SensorData + fields = [ + "farm_uuid", + "center_location_id", + "weather_forecast_id", + "sensor_payload", + "plant_ids", + "irrigation_method_id", + "created_at", + "updated_at", + ] + + +class SensorParameterSerializer(serializers.Serializer): + """سریالایزر ورودی برای تعریف پارامترهای سنسورهای مختلف.""" + + sensor_key = serializers.CharField(max_length=64, required=False, default=DEFAULT_SENSOR_KEY) + code = serializers.CharField(max_length=64) + name_fa = serializers.CharField(max_length=128) + unit = serializers.CharField(max_length=32, required=False, allow_blank=True) + data_type = serializers.CharField( + max_length=32, + required=False, + default=DEFAULT_SENSOR_DATA_TYPE, + ) + metadata = serializers.JSONField(required=False, default=dict) + + +class FarmCenterLocationSerializer(serializers.Serializer): + id = serializers.IntegerField() + lat = serializers.DecimalField(max_digits=9, decimal_places=6) + lon = serializers.DecimalField(max_digits=9, decimal_places=6) + farm_boundary = serializers.JSONField() + input_block_count = serializers.IntegerField() + block_layout = serializers.JSONField() + + +class WeatherForecastDetailSerializer(serializers.ModelSerializer): + class Meta: + model = WeatherForecast + fields = [ + "id", + "forecast_date", + "temperature_min", + "temperature_max", + "temperature_mean", + "precipitation", + "precipitation_probability", + "humidity_mean", + "wind_speed_max", + "et0", + "weather_code", + ] + + +class FarmSoilPayloadSerializer(serializers.Serializer): + resolved_metrics = serializers.JSONField() + metric_sources = serializers.JSONField() + satellite_snapshots = serializers.JSONField() + + +class PlantCatalogSnapshotSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(source="backend_plant_id", read_only=True) + + class Meta: + model = PlantCatalogSnapshot + fields = [ + "id", + "backend_plant_id", + "name", + "slug", + "icon", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "growth_stages", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + "is_active", + "source_updated_at", + "updated_at", + ] + + +class FarmPlantAssignmentSerializer(serializers.ModelSerializer): + plant_id = serializers.IntegerField(source="plant.backend_plant_id", read_only=True) + plant = PlantCatalogSnapshotSerializer(read_only=True) + + class Meta: + model = FarmPlantAssignment + fields = [ + "plant_id", + "position", + "stage", + "metadata", + "assigned_at", + "updated_at", + "plant", + ] + + +class FarmDetailSerializer(serializers.Serializer): + center_location = FarmCenterLocationSerializer() + weather = WeatherForecastDetailSerializer(allow_null=True) + sensor_payload = serializers.JSONField() + sensor_schema = serializers.JSONField() + soil = FarmSoilPayloadSerializer() + plant_ids = serializers.ListField(child=serializers.IntegerField()) + plants = PlantCatalogSnapshotSerializer(many=True) + plant_assignments = FarmPlantAssignmentSerializer(many=True) + irrigation_method_id = serializers.IntegerField(allow_null=True) + irrigation_method = IrrigationMethodSerializer(allow_null=True) + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() diff --git a/Modules/Ai/farm_data/services.py b/Modules/Ai/farm_data/services.py new file mode 100644 index 0000000..f999168 --- /dev/null +++ b/Modules/Ai/farm_data/services.py @@ -0,0 +1,761 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP +from numbers import Number +import logging +import warnings + +from django.conf import settings +from django.apps import apps +from django.db import transaction +from django.utils.dateparse import parse_datetime + +import requests + +from location_data.block_subdivision import create_or_get_block_subdivision +from location_data.models import BlockSubdivision, SoilLocation +from location_data.satellite_snapshot import ( + build_location_block_satellite_snapshots, + build_location_satellite_snapshot, +) +from irrigation.serializers import IrrigationMethodSerializer +from weather.models import WeatherForecast + +from .models import ( + FarmPlantAssignment, + ParameterUpdateLog, + PlantCatalogSnapshot, + SensorData, + SensorParameter, +) +from .serializers import PlantCatalogSnapshotSerializer, WeatherForecastDetailSerializer + + +DECIMAL_PRECISION = Decimal("0.000001") +logger = logging.getLogger(__name__) + + +class ExternalDataSyncError(Exception): + """خطا در همگام‌سازی داده از سرویس‌های بیرونی.""" + + +class BackendSyncError(Exception): + """خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend.""" + + +class LegacyFarmPlantRelationWarning(DeprecationWarning): + """هشدار برای relation قدیمی SensorData.plants.""" + + +PARAMETER_LABEL_OVERRIDES = { + "soil_moisture": "رطوبت خاک", + "soil_temperature": "دمای خاک", + "soil_ph": "pH خاک", + "electrical_conductivity": "هدایت الکتریکی", + "nitrogen": "نیتروژن", + "phosphorus": "فسفر", + "potassium": "پتاسیم", +} +PARAMETER_UNIT_OVERRIDES = { + "soil_moisture": "%", + "soil_temperature": "°C", + "soil_ph": "", + "electrical_conductivity": "dS/m", + "nitrogen": "mg/kg", + "phosphorus": "mg/kg", + "potassium": "mg/kg", +} + + +def get_backend_plant_base_url() -> str: + return getattr(settings, "BACKEND_PLANT_SYNC_BASE_URL", "").rstrip("/") + + +def get_backend_plant_timeout() -> int: + return int(getattr(settings, "BACKEND_PLANT_SYNC_TIMEOUT", 20)) + + +def get_backend_plant_headers() -> dict[str, str]: + headers = {"Accept": "application/json"} + api_key = getattr(settings, "BACKEND_PLANT_SYNC_API_KEY", "").strip() + if api_key: + headers["X-API-Key"] = api_key + headers["Authorization"] = f"Api-Key {api_key}" + return headers + + +def _extract_envelope_list(payload): + if isinstance(payload, list): + return payload + if not isinstance(payload, dict): + return [] + data = payload.get("data") + if isinstance(data, list): + return data + result = payload.get("result") + if isinstance(result, list): + return result + if isinstance(data, dict) and isinstance(data.get("result"), list): + return data["result"] + return [] + + +def _normalize_growth_stages(item: dict) -> list[str]: + stages = item.get("growth_stages") + if isinstance(stages, list): + return [str(stage).strip() for stage in stages if str(stage).strip()] + + growth_stage = str(item.get("growth_stage") or "").strip() + if not growth_stage: + return [] + return [part.strip() for part in growth_stage.replace("،", ",").split(",") if part.strip()] + + +def _snapshot_defaults_from_payload(item: dict) -> dict: + source_updated_at = parse_datetime(str(item.get("updated_at") or "").strip()) if item.get("updated_at") else None + return { + "name": str(item.get("name") or "").strip(), + "slug": str(item.get("slug") or "").strip(), + "icon": str(item.get("icon") or "leaf").strip() or "leaf", + "description": str(item.get("description") or "").strip(), + "metadata": item.get("metadata") if isinstance(item.get("metadata"), dict) else {}, + "light": str(item.get("light") or "").strip(), + "watering": str(item.get("watering") or "").strip(), + "soil": str(item.get("soil") or "").strip(), + "temperature": str(item.get("temperature") or "").strip(), + "growth_stage": str(item.get("growth_stage") or "").strip(), + "growth_stages": _normalize_growth_stages(item), + "planting_season": str(item.get("planting_season") or "").strip(), + "harvest_time": str(item.get("harvest_time") or "").strip(), + "spacing": str(item.get("spacing") or "").strip(), + "fertilizer": str(item.get("fertilizer") or "").strip(), + "health_profile": item.get("health_profile") if isinstance(item.get("health_profile"), dict) else {}, + "irrigation_profile": item.get("irrigation_profile") if isinstance(item.get("irrigation_profile"), dict) else {}, + "growth_profile": item.get("growth_profile") if isinstance(item.get("growth_profile"), dict) else {}, + "is_active": bool(item.get("is_active", True)), + "source_updated_at": source_updated_at, + } + + +def sync_plant_catalog_from_backend(plant_payloads: list[dict] | None = None) -> list[PlantCatalogSnapshot]: + if plant_payloads is None: + base_url = get_backend_plant_base_url() + if not base_url: + raise BackendSyncError("BACKEND_PLANT_SYNC_BASE_URL is not configured.") + try: + response = requests.get( + f"{base_url}/api/plants/", + headers=get_backend_plant_headers(), + timeout=get_backend_plant_timeout(), + ) + except requests.RequestException as exc: + raise BackendSyncError(f"Backend plant catalog request failed: {exc}") from exc + if response.status_code >= 400: + raise BackendSyncError(f"Backend plant catalog returned status {response.status_code}.") + plant_payloads = _extract_envelope_list(response.json()) + + snapshots: list[PlantCatalogSnapshot] = [] + with transaction.atomic(): + for item in plant_payloads or []: + if not isinstance(item, dict): + continue + plant_id = item.get("id") or item.get("backend_plant_id") + if plant_id in (None, ""): + continue + snapshot, _ = PlantCatalogSnapshot.objects.update_or_create( + backend_plant_id=int(plant_id), + defaults=_snapshot_defaults_from_payload(item), + ) + snapshots.append(snapshot) + return snapshots + + +def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: list[int] | None) -> list[PlantCatalogSnapshot]: + if backend_plant_ids is None: + return list(get_farm_plant_snapshots(farm)) + + normalized_ids = [int(plant_id) for plant_id in backend_plant_ids] + snapshots = list(PlantCatalogSnapshot.objects.filter(backend_plant_id__in=normalized_ids)) + snapshot_by_backend_id = {snapshot.backend_plant_id: snapshot for snapshot in snapshots} + missing_ids = [plant_id for plant_id in normalized_ids if plant_id not in snapshot_by_backend_id] + if missing_ids: + raise BackendSyncError( + "Plant catalog snapshot missing for backend ids: " + + ", ".join(str(plant_id) for plant_id in missing_ids) + ) + + with transaction.atomic(): + FarmPlantAssignment.objects.filter(farm=farm).exclude( + plant__backend_plant_id__in=normalized_ids + ).delete() + for position, backend_plant_id in enumerate(normalized_ids): + FarmPlantAssignment.objects.update_or_create( + farm=farm, + plant=snapshot_by_backend_id[backend_plant_id], + defaults={"position": position}, + ) + snapshots_in_order = [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids] + reconcile_legacy_farm_plants_relation(farm, snapshots_in_order) + return snapshots_in_order + + +def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]: + return list( + farm.plant_assignments.select_related("plant").order_by("position", "id") + ) + + +def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]: + return [assignment.plant for assignment in get_farm_plant_assignments(farm)] + + +def reconcile_legacy_farm_plants_relation( + farm: SensorData, + snapshots: list[PlantCatalogSnapshot] | None = None, +) -> None: + snapshots = list(snapshots if snapshots is not None else get_farm_plant_snapshots(farm)) + Plant = apps.get_model("plant", "Plant") + if Plant is None: + return + names = [snapshot.name for snapshot in snapshots if snapshot and snapshot.name] + if not names: + farm.plants.clear() + return + legacy_plants = list(Plant.objects.filter(name__in=names).order_by("name", "id")) + farm.plants.set(legacy_plants) + + +def get_canonical_farm_record(farm_uuid: str) -> SensorData | None: + return ( + SensorData.objects.select_related( + "center_location", + "weather_forecast", + "irrigation_method", + ) + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=farm_uuid) + .first() + ) + + +def get_legacy_farm_plants(farm: SensorData): + warnings.warn( + "SensorData.plants is deprecated; use farm_data.services canonical plant snapshot helpers instead.", + LegacyFarmPlantRelationWarning, + stacklevel=2, + ) + return farm.plants.all() + + +def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None: + assignments = get_farm_plant_assignments(farm) + return assignments[0].plant if assignments else None + + +def get_farm_plant_snapshot_by_name( + farm: SensorData, + plant_name: str | None, +) -> PlantCatalogSnapshot | None: + normalized_name = str(plant_name or "").strip().lower() + if not normalized_name: + return get_primary_plant_snapshot(farm) + for assignment in get_farm_plant_assignments(farm): + if assignment.plant.name.strip().lower() == normalized_name: + return assignment.plant + return get_primary_plant_snapshot(farm) + + +def clone_snapshot_as_runtime_plant( + snapshot: PlantCatalogSnapshot | None, + *, + growth_stage: str | None = None, +): + if snapshot is None: + return None + + class RuntimePlant: + pass + + runtime = RuntimePlant() + for field_name in ( + "backend_plant_id", + "name", + "slug", + "icon", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "growth_stages", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + "is_active", + ): + setattr(runtime, field_name, getattr(snapshot, field_name)) + if growth_stage: + runtime.growth_stage = growth_stage + runtime.id = snapshot.backend_plant_id + return runtime + + +def get_runtime_plant_for_farm( + farm: SensorData, + *, + plant_name: str | None = None, + growth_stage: str | None = None, +): + snapshot = get_farm_plant_snapshot_by_name(farm, plant_name) + return clone_snapshot_as_runtime_plant(snapshot, growth_stage=growth_stage) + + +def list_runtime_plants_for_farm(farm: SensorData) -> list[object]: + return [clone_snapshot_as_runtime_plant(snapshot) for snapshot in get_farm_plant_snapshots(farm)] + + +def build_plant_text_from_snapshot( + plant: PlantCatalogSnapshot | None, + growth_stage: str, +) -> str | None: + if plant is None: + return None + + lines = [ + f"نام گیاه: {plant.name}", + f"مرحله رشد: {growth_stage}", + ] + if plant.light: + lines.append(f"نور مورد نیاز: {plant.light}") + if plant.watering: + lines.append(f"آبیاری: {plant.watering}") + if plant.soil: + lines.append(f"خاک مناسب: {plant.soil}") + if plant.temperature: + lines.append(f"دمای مناسب: {plant.temperature}") + if plant.planting_season: + lines.append(f"فصل کاشت: {plant.planting_season}") + if plant.harvest_time: + lines.append(f"زمان برداشت: {plant.harvest_time}") + if plant.spacing: + lines.append(f"فاصله کاشت: {plant.spacing}") + if plant.fertilizer: + lines.append(f"کود مناسب: {plant.fertilizer}") + return "\n".join(lines) + + +def build_farm_plant_context(farm_uuid: str) -> dict | None: + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + return None + assignments = get_farm_plant_assignments(farm) + snapshots = [assignment.plant for assignment in assignments] + return { + "farm": farm, + "plant_ids": [plant.backend_plant_id for plant in snapshots], + "plants": PlantCatalogSnapshotSerializer(snapshots, many=True).data, + "plant_snapshots": snapshots, + "plant_assignments": assignments, + "primary_plant": snapshots[0] if snapshots else None, + } + + +def infer_sensor_parameter_data_type(value: object) -> str: + if isinstance(value, bool): + return "bool" + if isinstance(value, int) and not isinstance(value, bool): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "list" + if isinstance(value, dict): + return "object" + return "string" + + +def build_parameter_defaults(sensor_key: str, code: str, value: object) -> dict[str, object]: + return { + "name_fa": PARAMETER_LABEL_OVERRIDES.get(code) or code.replace("_", " ").strip(), + "unit": PARAMETER_UNIT_OVERRIDES.get(code, ""), + "data_type": infer_sensor_parameter_data_type(value), + "metadata": { + "source": "auto_discovered", + "sensor_key": sensor_key, + "code": code, + }, + } + + +def sync_sensor_parameters_from_payload(sensor_payload: dict | None) -> list[SensorParameter]: + if not isinstance(sensor_payload, dict): + return [] + + synced_parameters: list[SensorParameter] = [] + with transaction.atomic(): + for sensor_key, sensor_values in sensor_payload.items(): + if not isinstance(sensor_values, dict): + continue + for code, value in sensor_values.items(): + defaults = build_parameter_defaults(sensor_key, code, value) + parameter, created = SensorParameter.objects.get_or_create( + sensor_key=sensor_key, + code=code, + defaults=defaults, + ) + if created: + ParameterUpdateLog.objects.create( + parameter=parameter, + action=ParameterUpdateLog.ACTION_ADDED, + payload={ + "sensor_key": parameter.sensor_key, + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + "source": "farm_data_auto_sync", + }, + ) + synced_parameters.append(parameter) + return synced_parameters + + +def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str, list[dict[str, object]]]: + parameter_queryset = SensorParameter.objects.order_by("sensor_key", "code") + if sensor_payload and isinstance(sensor_payload, dict): + parameter_queryset = parameter_queryset.filter(sensor_key__in=list(sensor_payload.keys())) + + catalog: dict[str, list[dict[str, object]]] = {} + for parameter in parameter_queryset: + catalog.setdefault(parameter.sensor_key, []).append( + { + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + } + ) + return catalog + + +def get_farm_details(farm_uuid: str): + farm = get_canonical_farm_record(farm_uuid) + if farm is None: + return None + + sync_sensor_parameters_from_payload(farm.sensor_payload) + + center_location = farm.center_location + weather = farm.weather_forecast + if weather is None: + weather = ( + center_location.weather_forecasts.order_by("-forecast_date", "-id").first() + ) + + latest_satellite = build_location_satellite_snapshot(center_location) + soil_metrics = dict(latest_satellite.get("resolved_metrics") or {}) + sensor_metrics, sensor_metric_sources = _resolve_sensor_metrics(farm.sensor_payload) + + resolved_metrics = dict(soil_metrics) + metric_sources = {key: "remote_sensing" for key in soil_metrics} + for key, value in sensor_metrics.items(): + resolved_metrics[key] = value + metric_sources[key] = sensor_metric_sources[key] + + plant_assignments = get_farm_plant_assignments(farm) + plant_snapshots = [assignment.plant for assignment in plant_assignments] + + return { + "center_location": { + "id": center_location.id, + "lat": center_location.latitude, + "lon": center_location.longitude, + "farm_boundary": center_location.farm_boundary, + "input_block_count": center_location.input_block_count, + "block_layout": center_location.block_layout, + }, + "weather": WeatherForecastDetailSerializer(weather).data if weather else None, + "sensor_payload": farm.sensor_payload or {}, + "sensor_schema": get_sensor_parameter_catalog(farm.sensor_payload), + "soil": { + "resolved_metrics": resolved_metrics, + "metric_sources": metric_sources, + "satellite_snapshots": build_location_block_satellite_snapshots(center_location), + }, + "plant_ids": [plant.backend_plant_id for plant in plant_snapshots], + "plants": PlantCatalogSnapshotSerializer(plant_snapshots, many=True).data, + "plant_assignments": [ + { + "plant_id": assignment.plant.backend_plant_id, + "position": assignment.position, + "stage": assignment.stage, + "metadata": assignment.metadata, + "assigned_at": assignment.assigned_at, + "updated_at": assignment.updated_at, + "plant": PlantCatalogSnapshotSerializer(assignment.plant).data, + } + for assignment in plant_assignments + ], + "irrigation_method_id": farm.irrigation_method_id, + "irrigation_method": ( + IrrigationMethodSerializer(farm.irrigation_method).data + if farm.irrigation_method + else None + ), + "created_at": farm.created_at, + "updated_at": farm.updated_at, + } + + +def resolve_center_location_from_boundary( + farm_boundary: dict | list, + block_count: int = 1, +) -> SoilLocation: + """ + مرز مزرعه را می‌گیرد، مرکز را محاسبه می‌کند و رکورد SoilLocation را + ایجاد/به‌روزرسانی می‌کند. + """ + points = _extract_boundary_points(farm_boundary) + if not points: + raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") + + normalized_points = _normalize_points(points) + if len(normalized_points) < 3: + raise ValueError("farm_boundary باید حداقل 3 گوشه معتبر داشته باشد.") + + center_lat, center_lon = _compute_polygon_centroid(normalized_points) + serialized_boundary = _serialize_boundary(farm_boundary) + normalized_block_count = max(int(block_count or 1), 1) + + with transaction.atomic(): + location, created = SoilLocation.objects.get_or_create( + latitude=center_lat, + longitude=center_lon, + defaults={ + "farm_boundary": serialized_boundary, + "input_block_count": normalized_block_count, + }, + ) + if created: + location.set_input_block_count(normalized_block_count) + location.farm_boundary = serialized_boundary + location.save(update_fields=["farm_boundary", "input_block_count", "block_layout", "updated_at"]) + if normalized_block_count == 1: + _create_initial_block_subdivision(location, serialized_boundary) + else: + changed_fields = [] + if location.farm_boundary != serialized_boundary: + location.farm_boundary = serialized_boundary + changed_fields.append("farm_boundary") + if location.input_block_count != normalized_block_count: + location.set_input_block_count(normalized_block_count) + changed_fields.extend(["input_block_count", "block_layout"]) + if changed_fields: + changed_fields.append("updated_at") + location.save(update_fields=changed_fields) + return location + + +def resolve_weather_for_location(location: SoilLocation) -> WeatherForecast | None: + return ( + WeatherForecast.objects.filter(location=location) + .order_by("-forecast_date", "-id") + .first() + ) + + +def ensure_location_and_weather_data(location: SoilLocation) -> tuple[SoilLocation, WeatherForecast | None]: + """ + در فاز فعلی برای location_data و بلوک‌ها هیچ ریکوئست خارجی زده نمی‌شود + و فقط داده‌های محلی موجود برگردانده می‌شوند. + """ + weather_forecast = resolve_weather_for_location(location) + return location, weather_forecast + + +def _create_initial_block_subdivision( + location: SoilLocation, + block_boundary: dict | list, +) -> BlockSubdivision: + subdivision, _created = create_or_get_block_subdivision( + location=location, + block_code="block-1", + boundary=block_boundary, + ) + return subdivision + + +def _resolve_sensor_metrics(sensor_payload: dict | None) -> tuple[dict, dict]: + if not isinstance(sensor_payload, dict): + return {}, {} + + readings_by_metric: dict[str, list[tuple[str, object]]] = {} + for sensor_key, sensor_values in sorted(sensor_payload.items()): + if not isinstance(sensor_values, dict): + continue + for metric_key, metric_value in sensor_values.items(): + readings_by_metric.setdefault(metric_key, []).append((sensor_key, metric_value)) + + resolved_metrics = {} + metric_sources = {} + for metric_key, readings in readings_by_metric.items(): + resolved_value, source = _resolve_metric_readings(readings) + resolved_metrics[metric_key] = resolved_value + metric_sources[metric_key] = source + return resolved_metrics, metric_sources + + +def _resolve_metric_readings(readings: list[tuple[str, object]]) -> tuple[object, dict[str, object]]: + if not readings: + return None, {"type": "sensor", "strategy": "empty", "sensor_keys": []} + + sensor_keys = [sensor_key for sensor_key, _value in readings] + distinct_values: list[object] = [] + for _sensor_key, value in readings: + if value not in distinct_values: + distinct_values.append(value) + + if len(distinct_values) == 1: + return distinct_values[0], { + "type": "sensor", + "strategy": "single_value", + "sensor_keys": sensor_keys, + "sensor_count": len(sensor_keys), + } + + numeric_values = [_coerce_numeric(value) for value in distinct_values] + if all(value is not None for value in numeric_values): + average = sum(numeric_values) / len(numeric_values) + resolved_value = _normalize_numeric_result(average, distinct_values) + return resolved_value, { + "type": "sensor", + "strategy": "average", + "sensor_keys": sensor_keys, + "sensor_count": len(sensor_keys), + "conflict": True, + "distinct_values": distinct_values, + } + + return distinct_values, { + "type": "sensor", + "strategy": "distinct_values", + "sensor_keys": sensor_keys, + "sensor_count": len(sensor_keys), + "conflict": True, + "distinct_values": distinct_values, + } + + +def _coerce_numeric(value: object) -> float | None: + if isinstance(value, bool): + return None + if isinstance(value, Number): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_numeric_result(value: float, source_values: list[object]) -> int | float: + if all(isinstance(item, int) and not isinstance(item, bool) for item in source_values): + if value.is_integer(): + return int(value) + return float(Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)) + + +def _extract_boundary_points(boundary: dict | list) -> list: + if isinstance(boundary, dict): + if boundary.get("type") == "Polygon": + coordinates = boundary.get("coordinates") or [] + if coordinates and isinstance(coordinates[0], list): + return coordinates[0] + return [] + if "corners" in boundary: + return boundary.get("corners") or [] + if isinstance(boundary, list): + return boundary + return [] + + +def _normalize_points(points: list) -> list[tuple[Decimal, Decimal]]: + normalized: list[tuple[Decimal, Decimal]] = [] + for point in points: + lat = lon = None + if isinstance(point, dict): + lat = point.get("lat", point.get("latitude")) + lon = point.get("lon", point.get("longitude")) + elif isinstance(point, (list, tuple)) and len(point) >= 2: + lon, lat = point[0], point[1] + + if lat is None or lon is None: + continue + + lat_decimal = Decimal(str(lat)) + lon_decimal = Decimal(str(lon)) + normalized.append((lat_decimal, lon_decimal)) + + if len(normalized) > 1 and normalized[0] == normalized[-1]: + normalized = normalized[:-1] + return normalized + + +def _serialize_boundary(boundary: dict | list) -> dict: + if isinstance(boundary, dict) and boundary.get("type") == "Polygon": + return boundary + raw_points = boundary.get("corners") if isinstance(boundary, dict) else boundary + normalized = _normalize_points(raw_points or []) + coordinates = [[float(lon), float(lat)] for lat, lon in normalized] + if coordinates and coordinates[0] != coordinates[-1]: + coordinates.append(coordinates[0]) + return { + "type": "Polygon", + "coordinates": [coordinates], + } + + +def _compute_polygon_centroid(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]: + polygon = list(points) + if polygon[0] != polygon[-1]: + polygon.append(polygon[0]) + + twice_area = Decimal("0") + centroid_lon = Decimal("0") + centroid_lat = Decimal("0") + + for index in range(len(polygon) - 1): + lat1, lon1 = polygon[index] + lat2, lon2 = polygon[index + 1] + cross = (lon1 * lat2) - (lon2 * lat1) + twice_area += cross + centroid_lon += (lon1 + lon2) * cross + centroid_lat += (lat1 + lat2) * cross + + if twice_area == 0: + return _compute_average_center(points) + + factor = Decimal("3") * twice_area + return ( + (centroid_lat / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP), + (centroid_lon / factor).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP), + ) + + +def _compute_average_center(points: list[tuple[Decimal, Decimal]]) -> tuple[Decimal, Decimal]: + lat_sum = sum(lat for lat, _ in points) + lon_sum = sum(lon for _, lon in points) + count = Decimal(len(points)) + return ( + (lat_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP), + (lon_sum / count).quantize(DECIMAL_PRECISION, rounding=ROUND_HALF_UP), + ) diff --git a/Modules/Ai/farm_data/tests/__init__.py b/Modules/Ai/farm_data/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/farm_data/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/farm_data/tests/test_farm_detail_api.py b/Modules/Ai/farm_data/tests/test_farm_detail_api.py new file mode 100644 index 0000000..b0aed8e --- /dev/null +++ b/Modules/Ai/farm_data/tests/test_farm_detail_api.py @@ -0,0 +1,402 @@ +from datetime import date +from unittest.mock import patch +import uuid + +from django.test import TestCase +from rest_framework.test import APIClient + +from location_data.models import BlockSubdivision, SoilLocation +from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter +from farm_data.services import ( + assign_farm_plants_from_backend_ids, + get_canonical_farm_record, + get_runtime_plant_for_farm, + list_runtime_plants_for_farm, +) +from irrigation.models import IrrigationMethod +from weather.models import WeatherForecast + +from farm_data.services import resolve_center_location_from_boundary + + +def square_boundary_for_center(lat: float, lon: float, delta: float = 0.01) -> dict: + return { + "type": "Polygon", + "coordinates": [ + [ + [lon - delta, lat - delta], + [lon + delta, lat - delta], + [lon + delta, lat + delta], + [lon - delta, lat + delta], + [lon - delta, lat - delta], + ] + ], + } + + +class FarmDetailApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.location = SoilLocation.objects.create( + latitude="35.700000", + longitude="51.400000", + farm_boundary={"type": "Polygon", "coordinates": []}, + ) + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=23.0, + temperature_mean=18.0, + precipitation=1.2, + humidity_mean=52.0, + ) + self.plant1 = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") + self.plant2 = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="خیار") + self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") + self.farm_uuid = uuid.uuid4() + self.farm = SensorData.objects.create( + farm_uuid=self.farm_uuid, + center_location=self.location, + weather_forecast=self.weather, + irrigation_method=self.irrigation_method, + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 33.5, + "nitrogen": 99.0, + } + }, + ) + assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) + + def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self): + farm = get_canonical_farm_record(str(self.farm_uuid)) + + self.assertIsNotNone(farm) + self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجه‌فرنگی"]) + self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار") + + def test_assignment_sync_reconciles_legacy_relation_for_transition(self): + self.assertEqual(list(self.farm.plants.values_list("name", flat=True)), ["خیار", "گوجه‌فرنگی"]) + + def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self): + farm = get_canonical_farm_record(str(self.farm_uuid)) + + resolved = get_runtime_plant_for_farm(farm, plant_name="گوجه‌فرنگی") + + self.assertIsNotNone(resolved) + self.assertEqual(resolved.name, "گوجه‌فرنگی") + self.assertEqual(resolved.id, self.plant1.backend_plant_id) + + def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + + self.assertNotIn("farm_uuid", payload) + self.assertEqual(payload["center_location"]["id"], self.location.id) + self.assertEqual(payload["weather"]["id"], self.weather.id) + self.assertEqual( + payload["sensor_payload"]["sensor-7-1"]["soil_moisture"], + 33.5, + ) + self.assertIn("sensor_schema", payload) + self.assertEqual(payload["sensor_schema"]["sensor-7-1"][0]["code"], "nitrogen") + + resolved_metrics = payload["soil"]["resolved_metrics"] + metric_sources = payload["soil"]["metric_sources"] + + self.assertEqual(resolved_metrics["nitrogen"], 99.0) + self.assertEqual(metric_sources["nitrogen"]["type"], "sensor") + self.assertEqual(metric_sources["nitrogen"]["strategy"], "single_value") + self.assertEqual(payload["soil"]["satellite_snapshots"], []) + self.assertCountEqual(payload["plant_ids"], [self.plant1.backend_plant_id, self.plant2.backend_plant_id]) + self.assertEqual(len(payload["plants"]), 2) + returned_plants = {item["id"]: item for item in payload["plants"]} + self.assertEqual(returned_plants[self.plant1.backend_plant_id]["name"], self.plant1.name) + self.assertEqual(returned_plants[self.plant2.backend_plant_id]["name"], self.plant2.name) + self.assertIn("light", returned_plants[self.plant1.backend_plant_id]) + self.assertEqual(len(payload["plant_assignments"]), 2) + self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id) + self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) + + def test_returns_404_when_farm_is_missing(self): + response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "farm یافت نشد.") + + def test_aggregates_conflicting_metrics_from_multiple_sensors_without_overwrite(self): + self.farm.sensor_payload = { + "sensor-a": { + "soil_moisture": 20.0, + "nitrogen": 90.0, + "status": "ok", + }, + "sensor-b": { + "soil_moisture": 40.0, + "nitrogen": 110.0, + "status": "needs-check", + }, + } + self.farm.save(update_fields=["sensor_payload"]) + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + resolved_metrics = payload["soil"]["resolved_metrics"] + metric_sources = payload["soil"]["metric_sources"] + + self.assertEqual(resolved_metrics["soil_moisture"], 30.0) + self.assertEqual(metric_sources["soil_moisture"]["strategy"], "average") + self.assertCountEqual( + metric_sources["soil_moisture"]["sensor_keys"], + ["sensor-a", "sensor-b"], + ) + self.assertEqual(metric_sources["soil_moisture"]["distinct_values"], [20.0, 40.0]) + self.assertEqual(resolved_metrics["status"], ["ok", "needs-check"]) + self.assertEqual(metric_sources["status"]["strategy"], "distinct_values") + + def test_detail_auto_registers_unknown_sensor_parameters(self): + self.farm.sensor_payload = { + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 19.8, + } + } + self.farm.save(update_fields=["sensor_payload"]) + + response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + leaf_schema = payload["sensor_schema"]["leaf-sensor"] + self.assertCountEqual( + [item["code"] for item in leaf_schema], + ["leaf_temperature", "leaf_wetness"], + ) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() + ) + + +class FarmDataUpsertApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.location = SoilLocation.objects.create( + latitude="35.710000", + longitude="51.410000", + ) + self.boundary = square_boundary_for_center(35.71, 51.41) + self.weather = WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 11), + temperature_min=11.0, + temperature_max=24.0, + temperature_mean=17.5, + ) + self.irrigation_method = IrrigationMethod.objects.create(name="بارانی") + + def test_post_creates_farm_data_with_explicit_farm_uuid(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 31.2, + "nitrogen": 18.0, + } + }, + "irrigation_method_id": self.irrigation_method.id, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["data"]["farm_uuid"], str(farm_uuid)) + self.assertEqual(response.json()["data"]["center_location_id"], self.location.id) + self.assertEqual(response.json()["data"]["weather_forecast_id"], self.weather.id) + + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertEqual(farm.center_location_id, self.location.id) + self.assertEqual(farm.weather_forecast_id, self.weather.id) + self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id) + self.assertEqual( + farm.sensor_payload["sensor-7-1"]["soil_moisture"], + 31.2, + ) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() + ) + + def test_post_auto_registers_new_sensor_without_manual_parameter_creation(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "sensor_payload": { + "canopy-sensor-v2": { + "leaf_wetness": 12.4, + "leaf_temperature": 21.6, + "disease_pressure_index": 0.41, + } + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertTrue( + SensorParameter.objects.filter( + sensor_key="canopy-sensor-v2", + code="leaf_wetness", + ).exists() + ) + detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") + self.assertEqual(detail_response.status_code, 200) + schema = detail_response.json()["data"]["sensor_schema"]["canopy-sensor-v2"] + self.assertCountEqual( + [item["code"] for item in schema], + ["disease_pressure_index", "leaf_temperature", "leaf_wetness"], + ) + + def test_post_requires_farm_uuid_in_request_body(self): + response = self.client.post( + "/api/farm-data/", + data={ + "farm_boundary": self.boundary, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 31.2}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("farm_uuid", response.json()["data"]) + + def test_post_creates_center_location_from_boundary_when_missing(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": { + "corners": [ + {"lat": 50.0, "lon": 50.0}, + {"lat": 50.0, "lon": 50.02}, + {"lat": 50.02, "lon": 50.02}, + {"lat": 50.02, "lon": 50.0}, + ] + }, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertIsNotNone(farm.center_location_id) + self.assertEqual(str(farm.center_location.latitude), "50.010000") + self.assertEqual(str(farm.center_location.longitude), "50.010000") + self.assertIsNone(farm.weather_forecast_id) + self.assertEqual(farm.center_location.input_block_count, 1) + self.assertEqual(len(farm.center_location.block_layout["blocks"]), 1) + subdivision = BlockSubdivision.objects.get(soil_location=farm.center_location, block_code="block-1") + self.assertGreater(subdivision.grid_point_count, 0) + self.assertEqual(subdivision.grid_point_count, subdivision.centroid_count) + + def test_post_persists_requested_block_count_on_center_location(self): + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": self.boundary, + "block_count": 3, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 40.0}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertEqual(farm.center_location.input_block_count, 3) + self.assertEqual(len(farm.center_location.block_layout["blocks"]), 3) + self.assertFalse( + BlockSubdivision.objects.filter(soil_location=farm.center_location).exists() + ) + + def test_resolve_center_location_runs_subdivision_only_on_creation(self): + boundary = square_boundary_for_center(35.75, 51.45) + + first_location = resolve_center_location_from_boundary(boundary, block_count=1) + first_subdivision = BlockSubdivision.objects.get( + soil_location=first_location, + block_code="block-1", + ) + + second_location = resolve_center_location_from_boundary(boundary, block_count=1) + + self.assertEqual(first_location.id, second_location.id) + self.assertEqual( + BlockSubdivision.objects.filter( + soil_location=second_location, + block_code="block-1", + ).count(), + 1, + ) + self.assertEqual( + BlockSubdivision.objects.get( + soil_location=second_location, + block_code="block-1", + ).id, + first_subdivision.id, + ) + + def test_resolve_center_location_uses_geometric_centroid_for_concave_polygon(self): + location = resolve_center_location_from_boundary( + { + "corners": [ + {"lat": 0.0, "lon": 0.0}, + {"lat": 0.0, "lon": 4.0}, + {"lat": 4.0, "lon": 4.0}, + {"lat": 4.0, "lon": 0.0}, + {"lat": 1.0, "lon": 0.0}, + {"lat": 1.0, "lon": 3.0}, + {"lat": 3.0, "lon": 3.0}, + {"lat": 3.0, "lon": 1.0}, + {"lat": 0.0, "lon": 1.0}, + ] + } + ) + + self.assertEqual(str(location.latitude), "2.078947") + self.assertEqual(str(location.longitude), "2.078947") + + def test_post_keeps_missing_location_without_external_sync(self): + missing_boundary = square_boundary_for_center(36.0, 52.0) + farm_uuid = uuid.uuid4() + + response = self.client.post( + "/api/farm-data/", + data={ + "farm_uuid": str(farm_uuid), + "farm_boundary": missing_boundary, + "sensor_payload": {"sensor-7-1": {"soil_moisture": 44.0}}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + farm = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertIsNone(farm.weather_forecast_id) diff --git a/Modules/Ai/farm_data/urls.py b/Modules/Ai/farm_data/urls.py new file mode 100644 index 0000000..7391984 --- /dev/null +++ b/Modules/Ai/farm_data/urls.py @@ -0,0 +1,26 @@ +from django.urls import path + +from .views import FarmDetailView, FarmDataUpsertView, PlantCatalogSyncView, SensorParameterCreateView + +urlpatterns = [ + path( + "/detail/", + FarmDetailView.as_view(), + name="farm-detail", + ), + path( + "", + FarmDataUpsertView.as_view(), + name="farm-data-upsert", + ), + path( + "parameters/", + SensorParameterCreateView.as_view(), + name="farm-parameter-create", + ), + path( + "plants/sync/", + PlantCatalogSyncView.as_view(), + name="farm-data-plant-sync", + ), +] diff --git a/Modules/Ai/farm_data/views.py b/Modules/Ai/farm_data/views.py new file mode 100644 index 0000000..bb7be5f --- /dev/null +++ b/Modules/Ai/farm_data/views.py @@ -0,0 +1,481 @@ +from copy import deepcopy + +from django.db import transaction +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.integration_contract import build_integration_meta +from config.openapi import build_envelope_serializer, build_response +from .models import ParameterUpdateLog, SensorData, SensorParameter +from .serializers import ( + FarmDetailSerializer, + SensorDataResponseSerializer, + SensorDataUpdateSerializer, + SensorParameterSerializer, +) +from .services import ( + BackendSyncError, + assign_farm_plants_from_backend_ids, + ExternalDataSyncError, + ensure_location_and_weather_data, + get_farm_details, + resolve_center_location_from_boundary, + sync_sensor_parameters_from_payload, + sync_plant_catalog_from_backend, +) + + +SensorDataEnvelopeSerializer = build_envelope_serializer( + "SensorDataEnvelopeSerializer", + SensorDataResponseSerializer, +) +SensorDataValidationErrorSerializer = build_envelope_serializer( + "SensorDataValidationErrorSerializer", + data_required=False, + allow_null=True, +) +SensorDataNotFoundSerializer = build_envelope_serializer( + "SensorDataNotFoundSerializer", + data_required=False, + allow_null=True, +) +FarmDetailEnvelopeSerializer = build_envelope_serializer( + "FarmDetailEnvelopeSerializer", + FarmDetailSerializer, +) +SensorParameterResponseSerializer = build_envelope_serializer( + "SensorParameterEnvelopeSerializer", + inline_serializer( + name="SensorParameterPayloadSerializer", + fields={ + "id": drf_serializers.IntegerField(), + "sensor_key": drf_serializers.CharField(), + "code": drf_serializers.CharField(), + "name_fa": drf_serializers.CharField(), + "unit": drf_serializers.CharField(), + "data_type": drf_serializers.CharField(), + "metadata": drf_serializers.JSONField(), + "created_at": drf_serializers.DateTimeField(), + "action": drf_serializers.CharField(), + }, + ), +) + + +class FarmDataUpsertView(APIView): + """ + ایجاد یا آپدیت داده farm. + """ + + @extend_schema( + tags=["Farm Data"], + summary="ایجاد یا آپدیت داده farm", + description=( + "داده farm را با `POST /api/farm-data/` ایجاد یا آپدیت می‌کند. " + "`farm_uuid` باید از API ارسال شود و هرگز خودکار ساخته نمی‌شود. " + "مرز مزرعه را می‌گیرد، مرکز زمین را خودش محاسبه و در location_data ذخیره می‌کند. " + "رکورد آب‌وهوا هم از همان مرکز زمین به‌صورت خودکار پیدا می‌شود. " + "در این مرحله برای location_data هیچ ریکوئست خارجی برای بلوک‌ها زده نمی‌شود. " + 'خوانش‌ها داخل `sensor_payload` مثل `{"sensor-7-1": {...}}` نگه‌داری می‌شوند.' + ), + request=SensorDataUpdateSerializer, + responses={ + 200: build_response( + SensorDataEnvelopeSerializer, + "داده farm با موفقیت به‌روزرسانی شد.", + ), + 201: build_response( + SensorDataEnvelopeSerializer, + "داده farm با موفقیت ایجاد شد.", + ), + 400: build_response( + SensorDataValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 502: build_response( + SensorDataNotFoundSerializer, + "واکشی داده خاک یا آب‌وهوا از سرویس بیرونی ناموفق بود.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.3900, 35.7000], + [51.4100, 35.7000], + [51.4100, 35.7200], + [51.3900, 35.7200], + [51.3900, 35.7000], + ] + ], + }, + "block_count": 3, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + } + }, + }, + request_only=True, + ), + OpenApiExample( + "نمونه چند سنسور", + value={ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "farm_boundary": { + "corners": [ + {"lat": 35.7000, "lon": 51.3900}, + {"lat": 35.7000, "lon": 51.4100}, + {"lat": 35.7200, "lon": 51.4100}, + {"lat": 35.7200, "lon": 51.3900}, + ] + }, + "block_count": 2, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + }, + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 19.3, + }, + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = SensorDataUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm_uuid = serializer.validated_data["farm_uuid"] + farm_boundary = serializer.validated_data["farm_boundary"] + block_count = serializer.validated_data.get("block_count", 1) + plant_ids = serializer.validated_data.get("plant_ids") + irrigation_method_id = serializer.validated_data.get("irrigation_method_id") + sensor_payload = serializer.validated_data.get("sensor_payload", {}) + try: + center_location = resolve_center_location_from_boundary( + farm_boundary, + block_count=block_count, + ) + except ValueError as exc: + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": {"farm_boundary": [str(exc)]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + center_location, weather_forecast = ensure_location_and_weather_data( + center_location + ) + except ExternalDataSyncError as exc: + return Response( + {"code": 502, "msg": str(exc), "data": None}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + with transaction.atomic(): + sync_sensor_parameters_from_payload(sensor_payload) + farm_data, created = SensorData.objects.get_or_create( + farm_uuid=farm_uuid, + defaults={ + "center_location": center_location, + "weather_forecast": weather_forecast, + "sensor_payload": sensor_payload, + }, + ) + + if not created and sensor_payload: + merged_payload = deepcopy(farm_data.sensor_payload or {}) + for sensor_key, sensor_values in sensor_payload.items(): + current_values = merged_payload.get(sensor_key, {}) + if not isinstance(current_values, dict): + current_values = {} + current_values.update(sensor_values) + merged_payload[sensor_key] = current_values + farm_data.sensor_payload = merged_payload + elif created: + farm_data.sensor_payload = sensor_payload + + farm_data.center_location = center_location + farm_data.weather_forecast = weather_forecast + if "irrigation_method_id" in serializer.validated_data: + farm_data.irrigation_method_id = irrigation_method_id + if not created: + farm_data.save( + update_fields=[ + "center_location", + "weather_forecast", + "sensor_payload", + "irrigation_method", + "updated_at", + ] + ) + else: + farm_data.save() + + if plant_ids is not None: + try: + assign_farm_plants_from_backend_ids(farm_data, plant_ids) + except BackendSyncError as exc: + return Response( + {"code": 400, "msg": str(exc), "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + response_status = ( + status.HTTP_201_CREATED if created else status.HTTP_200_OK + ) + return Response( + { + "code": 201 if created else 200, + "msg": "success", + "data": SensorDataResponseSerializer(farm_data).data, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="provider", + source_service="ai_farm_data", + ownership="ai", + live=True, + cached=False, + generated_at=farm_data.updated_at, + notes=["AI farm_data stores a derived read-model enriched with location and weather data."], + ), + }, + status=response_status, + ) + + +class FarmDetailView(APIView): + @extend_schema( + tags=["Farm Data"], + summary="دریافت همه اطلاعات farm", + description=( + "اطلاعات تجمیعی farm را برمی‌گرداند. " + "برای resolved_metrics، داده‌های sensor روی داده‌های خاک اولویت دارند " + "و در حالت چند سنسوره، مقادیر متعارض به‌صورت deterministic تجمیع می‌شوند." + ), + responses={ + 200: build_response( + FarmDetailEnvelopeSerializer, + "اطلاعات farm با موفقیت بازگردانده شد.", + ), + 404: build_response( + SensorDataNotFoundSerializer, + "farm موردنظر یافت نشد.", + ), + }, + ) + def get(self, request, farm_uuid): + data = get_farm_details(str(farm_uuid)) + if data is None: + return Response( + {"code": 404, "msg": "farm یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="db", + source_service="ai_farm_data", + ownership="ai", + live=False, + cached=True, + snapshot_at=getattr(data, "get", lambda *_: None)("updated_at") if isinstance(data, dict) else None, + ), + }, + status=status.HTTP_200_OK, + ) + + +class PlantCatalogSyncView(APIView): + @extend_schema( + tags=["Farm Data"], + summary="همگام‌سازی کاتالوگ گیاه از Backend", + description="payload گیاه‌های canonical را از Backend دریافت و در `farm_data` snapshot می‌کند.", + request=drf_serializers.ListSerializer( + child=inline_serializer( + name="PlantCatalogSyncItem", + fields={ + "id": drf_serializers.IntegerField(), + "name": drf_serializers.CharField(), + }, + ) + ), + responses={ + 200: OpenApiResponse(description="کاتالوگ گیاه با موفقیت sync شد."), + 400: OpenApiResponse(description="payload نامعتبر است."), + }, + ) + def post(self, request): + if not isinstance(request.data, list): + return Response( + {"code": 400, "msg": "payload باید آرایه‌ای از گیاه‌ها باشد.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + snapshots = sync_plant_catalog_from_backend(request.data) + except BackendSyncError as exc: + return Response( + {"code": 400, "msg": str(exc), "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response( + { + "code": 200, + "msg": "success", + "data": { + "count": len(snapshots), + "plant_ids": [snapshot.backend_plant_id for snapshot in snapshots], + }, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="ai_farm_data_plant_catalog", + ownership="backend", + live=False, + cached=False, + generated_at=snapshots[-1].updated_at if snapshots else None, + notes=["Backend is canonical for plant catalog; AI stores snapshots for derived services."], + ), + }, + status=status.HTTP_200_OK, + ) + + +class SensorParameterCreateView(APIView): + """ + اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. + """ + + @extend_schema( + tags=["Farm Parameters"], + summary="افزودن/ویرایش پارامتر سنسور", + description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش می‌کند و در لاگ ثبت می‌شود.", + request=SensorParameterSerializer, + responses={ + 201: build_response( + SensorParameterResponseSerializer, + "پارامتر سنسور با موفقیت ایجاد یا ویرایش شد.", + ), + 400: build_response( + SensorDataValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "sensor_key": "sensor-7-1", + "code": "soil_moisture", + "name_fa": "رطوبت خاک", + "unit": "%", + "data_type": "float", + "metadata": {"min": 0, "max": 100}, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = SensorParameterSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + sensor_key = serializer.validated_data.get("sensor_key") + code = serializer.validated_data["code"] + name_fa = serializer.validated_data["name_fa"] + unit = serializer.validated_data.get("unit", "") + data_type = serializer.validated_data.get("data_type", "") + metadata = serializer.validated_data.get("metadata", {}) + + with transaction.atomic(): + parameter, created = SensorParameter.objects.update_or_create( + sensor_key=sensor_key, + code=code, + defaults={ + "name_fa": name_fa, + "unit": unit, + "data_type": data_type, + "metadata": metadata, + }, + ) + action = ( + ParameterUpdateLog.ACTION_ADDED + if created + else ParameterUpdateLog.ACTION_MODIFIED + ) + ParameterUpdateLog.objects.create( + parameter=parameter, + action=action, + payload={ + "sensor_key": parameter.sensor_key, + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + }, + ) + + return Response( + { + "code": 201, + "msg": "success", + "data": { + "id": parameter.id, + "sensor_key": parameter.sensor_key, + "code": parameter.code, + "name_fa": parameter.name_fa, + "unit": parameter.unit, + "data_type": parameter.data_type, + "metadata": parameter.metadata, + "created_at": parameter.created_at, + "action": action, + }, + "meta": build_integration_meta( + flow_type="ai_owned_derived_output", + source_type="db", + source_service="ai_farm_parameters", + ownership="ai", + live=False, + cached=False, + generated_at=parameter.created_at, + ), + }, + status=status.HTTP_201_CREATED, + ) diff --git a/Modules/Ai/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md b/Modules/Ai/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md new file mode 100644 index 0000000..bdbfb62 --- /dev/null +++ b/Modules/Ai/fertilization/FERTILIZATION_RECOMMENDATION_API_FIELDS.md @@ -0,0 +1,341 @@ +# Fertilization Recommendation API Fields + +این فایل فقط فیلدهای API مربوط به `POST /api/fertilization/recommend/` را توضیح می‌دهد. + +## Endpoint + +`POST /api/fertilization/recommend/` + +## ساختار کلی پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": { + "primary_recommendation": {}, + "nutrient_analysis": {}, + "application_guide": {}, + "alternative_recommendations": [], + "sections": [] + } +} +``` + +## فیلدهای Request + +### `farm_uuid` +- نوع: `string` +- اجباری: بله +- توضیح: شناسه یکتای مزرعه که توصیه برای آن تولید می‌شود. + +### `sensor_uuid` +- نوع: `string` +- اجباری: خیر +- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود. + +### `crop_id` +- نوع: `string` +- اجباری: خیر +- توضیح: شناسه یا نام محصول. اگر `plant_name` ارسال نشده باشد، همین مقدار به عنوان نام گیاه استفاده می‌شود. + +### `plant_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام گیاه یا محصول هدف برای تولید توصیه. + +### `growth_stage` +- نوع: `string` +- اجباری: خیر +- توضیح: مرحله رشد گیاه مثل `flowering` یا `fruiting`. + +### `query` +- نوع: `string` +- اجباری: خیر +- توضیح: سوال یا درخواست متنی اختیاری برای جهت دادن به توصیه. + +## فیلدهای لایه اول Response + +### `code` +- نوع: `number` +- توضیح: کد وضعیت پاسخ در قالب استاندارد API پروژه. + +### `msg` +- نوع: `string` +- توضیح: پیام وضعیت پاسخ. در حالت موفق معمولاً `success` است. + +### `data` +- نوع: `object` +- توضیح: بدنه اصلی توصیه کودهی ساختاریافته. + +## فیلدهای `data` + +### `primary_recommendation` +- نوع: `object` +- توضیح: پیشنهاد اصلی کودهی که فرانت باید در Hero Card و ماشین حساب مصرف از آن استفاده کند. + +### `nutrient_analysis` +- نوع: `object` +- توضیح: تحلیل ساختاریافته عناصر غذایی اصلی و ریزمغذی‌ها. + +### `application_guide` +- نوع: `object` +- توضیح: هشدار ایمنی و مراحل اجرای مصرف. + +### `alternative_recommendations` +- نوع: `array` +- توضیح: فهرست کودهای جایگزین قابل استفاده در شرایط مشابه. + +### `sections` +- نوع: `array` +- توضیح: ساختار legacy برای سازگاری با فرانت یا کلاینت‌های قدیمی. + +## فیلدهای `data.primary_recommendation` + +### `fertilizer_code` +- نوع: `string` +- توضیح: کد یکتای کود پیشنهادی. + +### `fertilizer_name` +- نوع: `string` +- توضیح: نام اصلی کود پیشنهادی برای نمایش. + +### `display_title` +- نوع: `string` +- توضیح: عنوان نمایشی آماده برای کارت اصلی. + +### `fertilizer_type` +- نوع: `string` +- توضیح: نوع کود مثل `NPK`. + +### `npk_ratio` +- نوع: `object` +- توضیح: نسبت NPK به صورت ساختاریافته. + +### `application_method` +- نوع: `object` +- توضیح: روش مصرف کود. + +### `application_interval` +- نوع: `object` +- توضیح: فاصله زمانی پیشنهادی بین دفعات مصرف. + +### `dosage` +- نوع: `object` +- توضیح: مقادیر پایه مصرف که فرانت با آن‌ها مقدار مورد نیاز را حساب می‌کند. + +### `reasoning` +- نوع: `string` +- توضیح: توضیح علمی و عملی درباره دلیل انتخاب این توصیه. + +### `summary` +- نوع: `string` +- توضیح: خلاصه کوتاه و مناسب نمایش در بخش اصلی فرانت. + +## فیلدهای `data.primary_recommendation.npk_ratio` + +### `n` +- نوع: `number` +- توضیح: درصد نیتروژن در کود پیشنهادی. + +### `p` +- نوع: `number` +- توضیح: درصد فسفر در کود پیشنهادی. + +### `k` +- نوع: `number` +- توضیح: درصد پتاسیم در کود پیشنهادی. + +### `label` +- نوع: `string` +- توضیح: نمایش متنی نسبت NPK مثل `20-20-20`. + +## فیلدهای `data.primary_recommendation.application_method` + +### `id` +- نوع: `string` +- توضیح: شناسه استاندارد روش مصرف مثل `fertigation` یا `foliar_fertigation`. + +### `label` +- نوع: `string` +- توضیح: متن آماده نمایش برای روش مصرف. + +## فیلدهای `data.primary_recommendation.application_interval` + +### `value` +- نوع: `number` +- توضیح: فاصله مصرف به صورت عددی. + +### `unit` +- نوع: `string` +- توضیح: واحد فاصله مصرف مثل `day`. + +### `label` +- نوع: `string` +- توضیح: متن آماده نمایش مثل `هر 14 روز`. + +## فیلدهای `data.primary_recommendation.dosage` + +### `base_amount_per_hectare` +- نوع: `number` +- توضیح: مقدار پایه مصرف در هر هکتار. + +### `base_amount_per_square_meter` +- نوع: `number` +- توضیح: مقدار پایه مصرف در هر متر مربع. + +### `unit` +- نوع: `string` +- توضیح: واحد مقدار مصرف مثل `kg`. + +### `label` +- نوع: `string` +- توضیح: متن آماده نمایش دوز مثل `65 کیلوگرم در هکتار`. + +### `calculation_basis` +- نوع: `string` +- توضیح: مبنای محاسبه دوز؛ معمولاً نام engine یا منبع محاسبه است. + +## نکته محاسبه برای فرانت + +فرانت باید مقدار نهایی را خودش با استفاده از نسبت پایه محاسبه کند. + +فرمول پیشنهادی: + +```text +مقدار کل = base_amount_per_square_meter × مساحت مزرعه +``` + +## فیلدهای `data.nutrient_analysis` + +### `macro` +- نوع: `array` +- توضیح: لیست عناصر اصلی شامل N، P و K. + +### `micro` +- نوع: `array` +- توضیح: لیست ریزمغذی‌ها مثل آهن، روی، منگنز و غیره. ممکن است خالی باشد. + +## فیلدهای هر آیتم در `data.nutrient_analysis.macro[]` و `data.nutrient_analysis.micro[]` + +### `key` +- نوع: `string` +- توضیح: کلید استاندارد عنصر مثل `n`، `p`، `k`، `fe` یا `zn`. + +### `name` +- نوع: `string` +- توضیح: نام نمایشی عنصر. + +### `value` +- نوع: `number` +- توضیح: مقدار عنصر، معمولاً به صورت درصد. + +### `unit` +- نوع: `string` +- توضیح: واحد عنصر، معمولاً `percent`. + +### `description` +- نوع: `string` +- توضیح: توضیح کوتاه درباره نقش یا اهمیت آن عنصر. + +## فیلدهای `data.application_guide` + +### `safety_warning` +- نوع: `string` +- توضیح: هشدار ایمنی و اجرایی قبل از مصرف. + +### `steps` +- نوع: `array` +- توضیح: مراحل پیشنهادی اجرا. + +## فیلدهای هر آیتم در `data.application_guide.steps[]` + +### `step_number` +- نوع: `number` +- توضیح: شماره مرحله اجرا. + +### `title` +- نوع: `string` +- توضیح: عنوان کوتاه مرحله. + +### `description` +- نوع: `string` +- توضیح: توضیح کامل مرحله. + +## فیلدهای هر آیتم در `data.alternative_recommendations[]` + +### `fertilizer_code` +- نوع: `string` +- توضیح: کد یکتای کود جایگزین. + +### `fertilizer_name` +- نوع: `string` +- توضیح: نام کود جایگزین. + +### `fertilizer_type` +- نوع: `string` +- توضیح: نوع کود جایگزین. + +### `usage_method` +- نوع: `string` +- توضیح: روش مصرف کود جایگزین. + +### `description` +- نوع: `string` +- توضیح: توضیح اینکه این جایگزین در چه شرایطی مفید است. + +## فیلدهای هر آیتم در `data.sections[]` + +### `type` +- نوع: `string` +- توضیح: نوع بخش مثل `recommendation`، `list` یا `warning`. + +### `title` +- نوع: `string` +- توضیح: عنوان بخش. + +### `icon` +- نوع: `string` +- توضیح: آیکون نمایشی بخش. + +### `content` +- نوع: `string` +- توضیح: متن اصلی بخش. + +### `items` +- نوع: `array` +- توضیح: لیست آیتم‌های متنی برای بخش‌های لیستی. + +### `fertilizerType` +- نوع: `string` +- توضیح: نسخه legacy نوع کود برای نمایش در کلاینت‌های قدیمی. + +### `amount` +- نوع: `string` +- توضیح: نسخه legacy مقدار مصرف برای کلاینت‌های قدیمی. + +### `applicationMethod` +- نوع: `string` +- توضیح: نسخه legacy روش مصرف. + +### `timing` +- نوع: `string` +- توضیح: نسخه legacy زمان مناسب اجرا. + +### `validityPeriod` +- نوع: `string` +- توضیح: نسخه legacy مدت اعتبار توصیه. + +### `expandableExplanation` +- نوع: `string` +- توضیح: نسخه legacy توضیح کامل‌تر برای نمایش بازشونده. + +## فیلدهای حذف شده + +فیلدهای زیر دیگر در خروجی اصلی استفاده نمی‌شوند: + +- `recommendation_id` +- `crop` +- `growth_stage` +- `total_amount` +- `area` در request diff --git a/Modules/Ai/fertilization/__init__.py b/Modules/Ai/fertilization/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/fertilization/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/fertilization/apps.py b/Modules/Ai/fertilization/apps.py new file mode 100644 index 0000000..886cd6d --- /dev/null +++ b/Modules/Ai/fertilization/apps.py @@ -0,0 +1,95 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class FertilizationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "fertilization" + verbose_name = "Fertilization" + tone_file = "config/tones/fertilization_tone.txt" + + @cached_property + def optimizer_defaults(self): + return { + "simulation_model": "Wofost81_NWLP_CWB_CNB", + "validity_days": 7, + "default_application_interval_days": 14, + "rain_delay_threshold_mm": 3.0, + "stage_targets": { + "initial": { + "n": 28.0, + "p": 20.0, + "k": 24.0, + "formula": "10-52-10", + "application_method": "استارتر نواری یا همراه آب آبیاری", + "timing": "همزمان با استقرار بوته و در ساعات خنک روز", + "application_interval_days": 10, + }, + "vegetative": { + "n": 55.0, + "p": 28.0, + "k": 42.0, + "formula": "20-20-20", + "application_method": "کودآبیاری یا سرک خاکی سبک", + "timing": "صبح زود و ترجیحا قبل از نوبت آبیاری", + "application_interval_days": 12, + }, + "flowering": { + "n": 42.0, + "p": 32.0, + "k": 58.0, + "formula": "15-10-30", + "application_method": "کودآبیاری یا محلول پاشی سبک", + "timing": "صبح زود و دور از تنش گرمایی ظهر", + "application_interval_days": 14, + }, + "fruiting": { + "n": 35.0, + "p": 24.0, + "k": 68.0, + "formula": "12-12-36", + "application_method": "کودآبیاری با تاکید بر پتاس", + "timing": "صبح زود یا نزدیک غروب", + "application_interval_days": 10, + }, + }, + "strategy_profiles": [ + { + "code": "maintenance", + "label": "تغذیه نگهدارنده", + "multiplier": 0.8, + "focus": "پایه متعادل", + "application_method": "کودآبیاری", + "formula_override": "", + }, + { + "code": "balanced", + "label": "تغذیه متعادل", + "multiplier": 1.0, + "focus": "ازت فسفر پتاس متعادل", + "application_method": "کودآبیاری", + "formula_override": "", + }, + { + "code": "corrective", + "label": "تغذیه اصلاحی", + "multiplier": 1.2, + "focus": "ازت و پتاس اصلاحی", + "application_method": "کودآبیاری", + "formula_override": "", + }, + ], + } + + def get_optimizer_defaults(self): + return self.optimizer_defaults + + @cached_property + def free_text_plan_parser_service(self): + from rag.services.fertilization_plan_parser import FertilizationPlanParserService + + return FertilizationPlanParserService() + + def get_free_text_plan_parser_service(self): + return self.free_text_plan_parser_service diff --git a/Modules/Ai/fertilization/serializers.py b/Modules/Ai/fertilization/serializers.py new file mode 100644 index 0000000..4a69763 --- /dev/null +++ b/Modules/Ai/fertilization/serializers.py @@ -0,0 +1,151 @@ +from rest_framework import serializers + + +class FertilizationRecommendRequestSerializer(serializers.Serializer): + """سریالایزر ورودی برای درخواست توصیه کودهی.""" + + farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه") + sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid") + crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه") + query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری") + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + + crop_id = (attrs.get("crop_id") or "").strip() + plant_name = (attrs.get("plant_name") or "").strip() + if crop_id and not plant_name: + attrs["plant_name"] = crop_id + attrs["farm_uuid"] = farm_uuid + return attrs + + +class NpkRatioSerializer(serializers.Serializer): + n = serializers.FloatField() + p = serializers.FloatField() + k = serializers.FloatField() + label = serializers.CharField() + + +class FertilizationApplicationMethodSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + + +class FertilizationApplicationIntervalSerializer(serializers.Serializer): + value = serializers.IntegerField() + unit = serializers.CharField() + label = serializers.CharField() + + +class FertilizationDosageSerializer(serializers.Serializer): + base_amount_per_hectare = serializers.FloatField() + base_amount_per_square_meter = serializers.FloatField() + unit = serializers.CharField() + label = serializers.CharField() + calculation_basis = serializers.CharField() + + +class PrimaryFertilizationRecommendationSerializer(serializers.Serializer): + fertilizer_code = serializers.CharField() + fertilizer_name = serializers.CharField() + display_title = serializers.CharField() + fertilizer_type = serializers.CharField() + npk_ratio = NpkRatioSerializer() + application_method = FertilizationApplicationMethodSerializer() + application_interval = FertilizationApplicationIntervalSerializer() + dosage = FertilizationDosageSerializer() + reasoning = serializers.CharField() + summary = serializers.CharField() + + +class FertilizationNutrientSerializer(serializers.Serializer): + key = serializers.CharField() + name = serializers.CharField() + value = serializers.FloatField() + unit = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationNutrientAnalysisSerializer(serializers.Serializer): + macro = FertilizationNutrientSerializer(many=True) + micro = FertilizationNutrientSerializer(many=True) + + +class FertilizationGuideStepSerializer(serializers.Serializer): + step_number = serializers.IntegerField() + title = serializers.CharField() + description = serializers.CharField() + + +class FertilizationApplicationGuideSerializer(serializers.Serializer): + safety_warning = serializers.CharField() + steps = FertilizationGuideStepSerializer(many=True) + + +class AlternativeFertilizationRecommendationSerializer(serializers.Serializer): + fertilizer_code = serializers.CharField() + fertilizer_name = serializers.CharField() + fertilizer_type = serializers.CharField() + usage_method = serializers.CharField() + description = serializers.CharField() + + +class FertilizationSectionSerializer(serializers.Serializer): + type = serializers.CharField() + title = serializers.CharField() + icon = serializers.CharField(required=False, allow_blank=True) + content = serializers.CharField(required=False, allow_blank=True) + items = serializers.ListField(child=serializers.CharField(), required=False) + fertilizerType = serializers.CharField(required=False, allow_blank=True) + amount = serializers.CharField(required=False, allow_blank=True) + applicationMethod = serializers.CharField(required=False, allow_blank=True) + timing = serializers.CharField(required=False, allow_blank=True) + validityPeriod = serializers.CharField(required=False, allow_blank=True) + expandableExplanation = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationRecommendationResponseDataSerializer(serializers.Serializer): + primary_recommendation = PrimaryFertilizationRecommendationSerializer() + nutrient_analysis = FertilizationNutrientAnalysisSerializer() + application_guide = FertilizationApplicationGuideSerializer() + alternative_recommendations = AlternativeFertilizationRecommendationSerializer(many=True) + sections = FertilizationSectionSerializer(many=True, required=False) + + +class FertilizationPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="توضیح آزاد کاربر درباره برنامه کودهی") + answers = serializers.JSONField(required=False, help_text="پاسخ های تکمیلی کاربر به سوالات مرحله قبل") + partial_plan = serializers.JSONField(required=False, help_text="داده استخراج شده مرحله قبل برای ادامه تکمیل") + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="شناسه مزرعه برای غنی سازی context") + + def validate(self, attrs): + message = (attrs.get("message") or "").strip() + answers = attrs.get("answers") + partial_plan = attrs.get("partial_plan") + if not message and not isinstance(answers, dict) and not isinstance(partial_plan, dict): + raise serializers.ValidationError( + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ) + return attrs + + +class PlanClarificationQuestionSerializer(serializers.Serializer): + id = serializers.CharField() + field = serializers.CharField() + question = serializers.CharField() + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationPlanParserResponseSerializer(serializers.Serializer): + status = serializers.CharField() + status_fa = serializers.CharField() + summary = serializers.CharField() + missing_fields = serializers.ListField(child=serializers.CharField()) + questions = PlanClarificationQuestionSerializer(many=True) + collected_data = serializers.JSONField() + final_plan = serializers.JSONField(required=False, allow_null=True) diff --git a/Modules/Ai/fertilization/urls.py b/Modules/Ai/fertilization/urls.py new file mode 100644 index 0000000..ba01118 --- /dev/null +++ b/Modules/Ai/fertilization/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import FertilizationPlanParserView, FertilizationRecommendView + +urlpatterns = [ + path("recommend/", FertilizationRecommendView.as_view(), name="fertilization-recommend"), + path("plan-from-text/", FertilizationPlanParserView.as_view(), name="fertilization-plan-from-text"), +] diff --git a/Modules/Ai/fertilization/views.py b/Modules/Ai/fertilization/views.py new file mode 100644 index 0000000..99c6d21 --- /dev/null +++ b/Modules/Ai/fertilization/views.py @@ -0,0 +1,234 @@ +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response + +from .serializers import ( + FertilizationPlanParserRequestSerializer, + FertilizationPlanParserResponseSerializer, + FertilizationRecommendationResponseDataSerializer, + FertilizationRecommendRequestSerializer, +) + + +FertilizationValidationErrorSerializer = build_envelope_serializer( + "FertilizationValidationErrorSerializer", + data_required=False, + allow_null=True, +) +FertilizationResponseSerializer = build_envelope_serializer( + "FertilizationResponseSerializer", + data_schema=FertilizationRecommendationResponseDataSerializer, +) +FertilizationPlanParserEnvelopeSerializer = build_envelope_serializer( + "FertilizationPlanParserEnvelopeSerializer", + data_schema=FertilizationPlanParserResponseSerializer, +) + + +class FertilizationRecommendView(APIView): + """ + توصیه کودهی ساختاریافته با ترکیب RAG و optimizer شبیه سازی. + """ + + @extend_schema( + tags=["Fertilization Recommendation"], + summary="درخواست توصیه کودهی ساختاریافته", + description=( + "داده های مزرعه، گیاه و مرحله رشد را دریافت می کند و " + "خروجی نهایی بهینه شده با ترکیب RAG و optimizer مبتنی بر crop_simulation/PCSE را برمی گرداند." + ), + request=FertilizationRecommendRequestSerializer, + responses={ + 200: build_response( + FertilizationResponseSerializer, + "توصیه کودهی ساختاریافته با موفقیت تولید شد.", + ), + 400: build_response( + FertilizationValidationErrorSerializer, + "پارامتر ورودی نامعتبر است.", + ), + 500: build_response( + FertilizationValidationErrorSerializer, + "خطا در تولید توصیه کودهی.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "crop_id": "wheat", + "growth_stage": "flowering", + }, + request_only=True, + ), + OpenApiExample( + "نمونه پاسخ", + value={ + "code": 200, + "msg": "success", + "data": { + "primary_recommendation": { + "fertilizer_code": "15-10-30", + "fertilizer_name": "کود کامل 15-10-30", + "display_title": "کود کامل 15-10-30", + "fertilizer_type": "NPK", + "npk_ratio": {"n": 15, "p": 10, "k": 30, "label": "15-10-30"}, + "application_method": { + "id": "foliar_fertigation", + "label": "کودآبیاری یا محلول پاشی سبک", + }, + "application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"}, + "dosage": { + "base_amount_per_hectare": 65, + "base_amount_per_square_meter": 0.0065, + "unit": "kg", + "label": "65 کیلوگرم در هکتار", + "calculation_basis": "crop_simulation_heuristic", + }, + "reasoning": "این ترکیب برای مرحله گلدهی و توازن نیازهای تغذیه ای مناسب است.", + "summary": "برای پشتیبانی از گلدهی و کاهش تنش تغذیه ای پیشنهاد می شود.", + }, + "nutrient_analysis": { + "macro": [ + { + "key": "n", + "name": "نیتروژن (N)", + "value": 15, + "unit": "percent", + "description": "نیتروژن برای حفظ رشد رویشی مهم است.", + } + ], + "micro": [], + }, + "application_guide": { + "safety_warning": "در ساعات خنک مصرف شود و از اختلاط ناسازگار خودداری کنید.", + "steps": [ + {"step_number": 1, "title": "آماده سازی", "description": "دوز را آماده کنید."}, + {"step_number": 2, "title": "تزریق یا پخش", "description": "طبق روش مصرف اجرا کنید."}, + {"step_number": 3, "title": "پایش", "description": "پاسخ مزرعه را بررسی کنید."}, + ], + }, + "alternative_recommendations": [], + }, + }, + response_only=True, + ), + ], + ) + def post(self, request): + from rag.services.fertilization import get_fertilization_recommendation + + serializer = FertilizationRecommendRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + + try: + result = get_fertilization_recommendation( + farm_uuid=validated["farm_uuid"], + plant_name=validated.get("plant_name"), + crop_id=validated.get("crop_id"), + growth_stage=validated.get("growth_stage"), + query=validated.get("query"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تولید توصیه کودهی: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + final_result = result.get("data") if isinstance(result, dict) else None + if not isinstance(final_result, dict): + final_result = {"sections": result.get("sections", [])} if isinstance(result, dict) else {} + + return Response( + {"code": 200, "msg": "success", "data": final_result}, + status=status.HTTP_200_OK, + ) + + +class FertilizationPlanParserView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + summary="استخراج برنامه کودهی از متن آزاد", + description=( + "توضیح متنی کاربر درباره برنامه کودهی را می گیرد و آن را به JSON ساختاریافته تبدیل می کند. " + "اگر اطلاعات کافی نباشد، سوالات تکمیلی لازم را برمی گرداند تا در درخواست بعدی پاسخ داده شوند." + ), + request=FertilizationPlanParserRequestSerializer, + responses={ + 200: build_response( + FertilizationPlanParserEnvelopeSerializer, + "نتیجه استخراج یا سوالات تکمیلی برنامه کودهی.", + ), + 400: build_response( + FertilizationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + FertilizationValidationErrorSerializer, + "خطا در پردازش برنامه کودهی.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست کامل", + value={ + "message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + }, + request_only=True, + ), + OpenApiExample( + "نمونه درخواست تکمیلی", + value={ + "partial_plan": { + "crop_name": "گندم", + "applications": [{"fertilizer_name": "20-20-20"}], + }, + "answers": { + "amount": "35 کیلوگرم در هکتار", + "timing": "هر 12 روز یک بار", + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = FertilizationPlanParserRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = apps.get_app_config("fertilization").get_free_text_plan_parser_service() + try: + result = service.parse_plan( + message=validated.get("message", ""), + answers=validated.get("answers"), + partial_plan=validated.get("partial_plan"), + farm_uuid=validated.get("farm_uuid"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پردازش برنامه کودهی: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "موفق", "data": result}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/integration_tests/README.md b/Modules/Ai/integration_tests/README.md new file mode 100644 index 0000000..e72cb41 --- /dev/null +++ b/Modules/Ai/integration_tests/README.md @@ -0,0 +1,19 @@ +Integration tests in this folder are intended to run against the project's +configured MySQL backend, so the API flow is exercised with a real relational +database instead of in-memory fixtures. + +Recommended command: + +```bash +DJANGO_SETTINGS_MODULE=config.settings python manage.py test integration_tests +``` + +Notes: +- Django will still create an isolated test database on the same MySQL server + (for example `test_ai` when `DB_NAME=ai`). +- External AI providers, remote sensing calls, and Celery workers are stubbed + inside the tests so the suite stays deterministic while database writes stay + real. +- The tests in this folder use the full `config.urls` router, not the reduced + `config.test_urls` router. + diff --git a/Modules/Ai/integration_tests/__init__.py b/Modules/Ai/integration_tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/integration_tests/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/integration_tests/base.py b/Modules/Ai/integration_tests/base.py new file mode 100644 index 0000000..2d82073 --- /dev/null +++ b/Modules/Ai/integration_tests/base.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any +import uuid + +from django.test import TransactionTestCase +from rest_framework.test import APIClient + +from location_data.models import NdviObservation, SoilLocation +from weather.models import WeatherForecast + + +UNSET = object() + + +def square_boundary(lat: float, lon: float, delta: float = 0.01) -> dict[str, Any]: + return { + "type": "Polygon", + "coordinates": [ + [ + [lon - delta, lat - delta], + [lon + delta, lat - delta], + [lon + delta, lat + delta], + [lon - delta, lat + delta], + [lon - delta, lat - delta], + ] + ], + } + + +class IntegrationAPITestCase(TransactionTestCase): + reset_sequences = True + databases = {"default"} + + primary_lat = 35.700000 + primary_lon = 51.400000 + forecast_start = date.today() + + def setUp(self) -> None: + super().setUp() + self.client = APIClient() + self.primary_boundary = square_boundary(self.primary_lat, self.primary_lon) + self.primary_location = self.create_complete_location( + lat=self.primary_lat, + lon=self.primary_lon, + boundary=self.primary_boundary, + ) + self.seed_weather_forecasts(self.primary_location, start=self.forecast_start, days=7) + self.seed_ndvi_observation(self.primary_location) + + def create_complete_location( + self, + *, + lat: float, + lon: float, + boundary: dict[str, Any] | None = None, + ) -> SoilLocation: + location = SoilLocation.objects.create( + latitude=f"{lat:.6f}", + longitude=f"{lon:.6f}", + farm_boundary=boundary or square_boundary(lat, lon), + ) + return location + + def seed_weather_forecasts( + self, + location: SoilLocation, + *, + start: date, + days: int, + temperature_base: float = 22.0, + et0_base: float = 3.4, + ) -> list[WeatherForecast]: + forecasts: list[WeatherForecast] = [] + for day_index in range(days): + forecasts.append( + WeatherForecast.objects.create( + location=location, + forecast_date=start + timedelta(days=day_index), + temperature_min=12.0 + day_index, + temperature_max=temperature_base + day_index, + temperature_mean=17.0 + day_index, + precipitation=1.2 if day_index % 3 == 0 else 0.0, + precipitation_probability=35.0 + day_index, + humidity_mean=48.0 + day_index, + wind_speed_max=10.0 + day_index, + et0=et0_base + (day_index * 0.2), + weather_code=0 if day_index == 0 else 2, + ) + ) + return forecasts + + def seed_ndvi_observation( + self, + location: SoilLocation, + *, + observation_date: date | None = None, + mean_ndvi: float = 0.73, + ) -> NdviObservation: + return NdviObservation.objects.create( + location=location, + observation_date=observation_date or self.forecast_start, + mean_ndvi=mean_ndvi, + ndvi_map={"type": "FeatureCollection", "features": []}, + vegetation_health_class="Healthy", + satellite_source="sentinel-2", + metadata={"suite": "integration"}, + ) + + def create_irrigation_method_via_api(self, name: str, **overrides: Any) -> dict[str, Any]: + payload = { + "name": name, + "category": "localized", + "description": "Primary drip line for the farm", + "water_efficiency_percent": 91.0, + "water_pressure_required": "1.5 bar", + "flow_rate": "4 l/h", + "coverage_area": "row-based", + "soil_type": "loam", + "climate_suitability": "dry", + } + payload.update(overrides) + response = self.client.post("/api/irrigation/", data=payload, format="json") + self.assertEqual(response.status_code, 201, response.json()) + return response.json()["data"] + + def create_plant_via_api(self, name: str, **overrides: Any) -> dict[str, Any]: + payload = { + "name": name, + "light": "full sun", + "watering": "every 2 days", + "soil": "loamy", + "temperature": "20-28C", + "growth_stage": "vegetative", + "planting_season": "spring", + "harvest_time": "90 days", + "spacing": "50 cm", + "fertilizer": "balanced NPK", + } + payload.update(overrides) + response = self.client.post("/api/plants/", data=payload, format="json") + self.assertEqual(response.status_code, 201, response.json()) + return response.json()["data"] + + def create_sensor_parameter_via_api(self, **overrides: Any) -> dict[str, Any]: + payload = { + "sensor_key": "sensor-7-1", + "code": "soil_moisture", + "name_fa": "soil moisture", + "unit": "%", + "data_type": "float", + "metadata": {"min": 0, "max": 100}, + } + payload.update(overrides) + response = self.client.post("/api/farm-data/parameters/", data=payload, format="json") + self.assertEqual(response.status_code, 201, response.json()) + return response.json()["data"] + + def upsert_farm_via_api( + self, + *, + farm_uuid: uuid.UUID, + plant_ids: list[int] | None = None, + irrigation_method_id: int | None | object = UNSET, + sensor_payload: dict[str, Any] | None = None, + boundary: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "farm_uuid": str(farm_uuid), + "farm_boundary": boundary or self.primary_boundary, + } + if plant_ids is not None: + payload["plant_ids"] = plant_ids + if irrigation_method_id is not UNSET: + payload["irrigation_method_id"] = irrigation_method_id + if sensor_payload is not None: + payload["sensor_payload"] = sensor_payload + response = self.client.post("/api/farm-data/", data=payload, format="json") + self.assertIn(response.status_code, {200, 201}, response.json()) + return response.json()["data"] diff --git a/Modules/Ai/integration_tests/test_management_api_flow.py b/Modules/Ai/integration_tests/test_management_api_flow.py new file mode 100644 index 0000000..79c33fa --- /dev/null +++ b/Modules/Ai/integration_tests/test_management_api_flow.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import uuid +from unittest.mock import patch + +from django.test import override_settings + +from farm_data.models import ParameterUpdateLog, SensorData, SensorParameter +from integration_tests.base import IntegrationAPITestCase +from plant.models import Plant + + +@override_settings(ROOT_URLCONF="config.urls") +class FarmManagementJourneyTests(IntegrationAPITestCase): + def test_full_management_journey_persists_farm_related_records(self) -> None: + primary_method = self.create_irrigation_method_via_api("Drip Prime") + backup_method = self.create_irrigation_method_via_api( + "Sprinkler Backup", + category="pressure", + water_efficiency_percent=78.0, + ) + + irrigation_list_response = self.client.get("/api/irrigation/") + self.assertEqual(irrigation_list_response.status_code, 200) + self.assertGreaterEqual(len(irrigation_list_response.json()["data"]), 2) + irrigation_detail_response = self.client.get(f"/api/irrigation/{primary_method['id']}/") + self.assertEqual(irrigation_detail_response.status_code, 200) + self.assertEqual(irrigation_detail_response.json()["data"]["name"], "Drip Prime") + + moisture_parameter = self.create_sensor_parameter_via_api() + self.assertEqual(moisture_parameter["action"], ParameterUpdateLog.ACTION_ADDED) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="sensor-7-1", code="soil_moisture").exists() + ) + + moisture_parameter_update = self.create_sensor_parameter_via_api( + metadata={"min": 5, "max": 85, "ui": "gauge"}, + ) + self.assertEqual(moisture_parameter_update["action"], ParameterUpdateLog.ACTION_MODIFIED) + self.assertEqual( + ParameterUpdateLog.objects.filter(parameter__code="soil_moisture").count(), + 2, + ) + + tomato = self.create_plant_via_api("Tomato") + cucumber = self.create_plant_via_api("Cucumber", watering="daily") + removable_plant = self.create_plant_via_api("Remove Plant") + + plants_list_response = self.client.get("/api/plants/") + self.assertEqual(plants_list_response.status_code, 200) + returned_names = {item["name"] for item in plants_list_response.json()["data"]} + self.assertTrue({"Tomato", "Cucumber", "Remove Plant"}.issubset(returned_names)) + + plant_catalog = self.create_plant_via_api( + "Pepper", + growth_stage="", + icon="sprout", + ) + Plant.objects.filter(pk=plant_catalog["id"]).update(growth_stage="", icon="") + plant_names_response = self.client.get("/api/plants/names/") + self.assertEqual(plant_names_response.status_code, 200) + plant_names_payload = { + item["name"]: item for item in plant_names_response.json()["data"] + } + self.assertEqual(plant_names_payload["Pepper"]["icon"], "leaf") + self.assertEqual( + plant_names_payload["Pepper"]["growth_stages"], + ["initial", "vegetative", "flowering", "fruiting", "maturity"], + ) + pepper = Plant.objects.get(pk=plant_catalog["id"]) + self.assertEqual( + pepper.growth_stage, + "initial, vegetative, flowering, fruiting, maturity", + ) + self.assertEqual(pepper.icon, "leaf") + + plant_patch_response = self.client.patch( + f"/api/plants/{tomato['id']}/", + data={"growth_stage": "flowering", "watering": "daily"}, + format="json", + ) + self.assertEqual(plant_patch_response.status_code, 200) + self.assertEqual(Plant.objects.get(pk=tomato["id"]).growth_stage, "flowering") + + plant_put_response = self.client.put( + f"/api/plants/{cucumber['id']}/", + data={ + "name": "Cucumber", + "light": "full sun", + "watering": "every day", + "soil": "sandy loam", + "temperature": "18-30C", + "growth_stage": "fruiting", + "planting_season": "spring", + "harvest_time": "70 days", + "spacing": "40 cm", + "fertilizer": "potassium rich", + }, + format="json", + ) + self.assertEqual(plant_put_response.status_code, 200) + + with patch( + "plant.views.fetch_plant_info_from_api", + return_value={ + "name": "Tomato", + "light": "full sun", + "watering": "daily", + "soil": "loamy", + "temperature": "20-28C", + "growth_stage": "flowering", + "planting_season": "spring", + "harvest_time": "90 days", + "spacing": "50 cm", + "fertilizer": "balanced NPK", + }, + ): + plant_fetch_response = self.client.post( + "/api/plants/fetch-info/", + data={"name": "Tomato"}, + format="json", + ) + self.assertEqual(plant_fetch_response.status_code, 200) + self.assertEqual(plant_fetch_response.json()["data"]["name"], "Tomato") + + plant_delete_response = self.client.delete(f"/api/plants/{removable_plant['id']}/") + self.assertEqual(plant_delete_response.status_code, 200) + self.assertFalse(Plant.objects.filter(pk=removable_plant["id"]).exists()) + + farm_uuid = uuid.uuid4() + created_farm = self.upsert_farm_via_api( + farm_uuid=farm_uuid, + plant_ids=[tomato["id"], cucumber["id"]], + irrigation_method_id=primary_method["id"], + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 41.2, + "soil_temperature": 23.4, + "soil_ph": 6.8, + "electrical_conductivity": 1.1, + "nitrogen": 17.0, + "phosphorus": 12.5, + "potassium": 21.0, + } + }, + ) + self.assertEqual(created_farm["farm_uuid"], str(farm_uuid)) + farm_record = SensorData.objects.get(farm_uuid=farm_uuid) + self.assertCountEqual( + list(farm_record.plants.values_list("id", flat=True)), + [tomato["id"], cucumber["id"]], + ) + self.assertEqual(farm_record.irrigation_method_id, primary_method["id"]) + self.assertEqual(farm_record.center_location_id, self.primary_location.id) + + updated_farm = self.upsert_farm_via_api( + farm_uuid=farm_uuid, + plant_ids=[tomato["id"]], + irrigation_method_id=backup_method["id"], + sensor_payload={ + "sensor-7-1": { + "nitrogen": 19.5, + "soil_moisture": 44.0, + }, + "leaf-sensor": { + "leaf_wetness": 11.0, + "leaf_temperature": 21.3, + }, + }, + ) + self.assertEqual(updated_farm["irrigation_method_id"], backup_method["id"]) + + farm_record.refresh_from_db() + self.assertEqual(farm_record.irrigation_method_id, backup_method["id"]) + self.assertCountEqual(list(farm_record.plants.values_list("id", flat=True)), [tomato["id"]]) + self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_temperature"], 23.4) + self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["soil_moisture"], 44.0) + self.assertEqual(farm_record.sensor_payload["sensor-7-1"]["nitrogen"], 19.5) + self.assertEqual(farm_record.sensor_payload["leaf-sensor"]["leaf_wetness"], 11.0) + self.assertTrue( + SensorParameter.objects.filter(sensor_key="leaf-sensor", code="leaf_wetness").exists() + ) + + farm_detail_response = self.client.get(f"/api/farm-data/{farm_uuid}/detail/") + self.assertEqual(farm_detail_response.status_code, 200) + farm_detail = farm_detail_response.json()["data"] + self.assertEqual(farm_detail["center_location"]["id"], self.primary_location.id) + self.assertEqual(farm_detail["irrigation_method_id"], backup_method["id"]) + self.assertEqual(farm_detail["plant_ids"], [tomato["id"]]) + self.assertEqual(farm_detail["plants"][0]["name"], "Tomato") + self.assertEqual( + farm_detail["sensor_payload"]["leaf-sensor"]["leaf_temperature"], + 21.3, + ) + self.assertCountEqual( + [item["code"] for item in farm_detail["sensor_schema"]["leaf-sensor"]], + ["leaf_temperature", "leaf_wetness"], + ) diff --git a/Modules/Ai/integration_tests/test_reporting_and_ai_api_flow.py b/Modules/Ai/integration_tests/test_reporting_and_ai_api_flow.py new file mode 100644 index 0000000..ae20aa0 --- /dev/null +++ b/Modules/Ai/integration_tests/test_reporting_and_ai_api_flow.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import patch +import uuid + +from django.apps import apps +from django.test import override_settings + +from crop_simulation.models import SimulationRun, SimulationScenario +from farm_alerts.models import FarmAlertNotification +from farm_data.models import SensorData +from integration_tests.base import IntegrationAPITestCase, square_boundary + + +class FakeAsyncResult: + def __init__(self, *, state: str, result=None, info=None): + self.state = state + self.result = result + self.info = info + + +@override_settings(ROOT_URLCONF="config.urls") +class ReportingAndAiJourneyTests(IntegrationAPITestCase): + def setUp(self) -> None: + super().setUp() + self.irrigation_method = self.create_irrigation_method_via_api("Analytics Drip") + self.primary_plant = self.create_plant_via_api("Tomato") + self.secondary_plant = self.create_plant_via_api("Pepper") + self.farm_uuid = uuid.uuid4() + self.upsert_farm_via_api( + farm_uuid=self.farm_uuid, + plant_ids=[self.primary_plant["id"], self.secondary_plant["id"]], + irrigation_method_id=self.irrigation_method["id"], + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 46.0, + "soil_temperature": 24.2, + "soil_ph": 6.5, + "electrical_conductivity": 1.3, + "nitrogen": 20.0, + "phosphorus": 11.0, + "potassium": 18.0, + "timestamp": "2026-04-10T06:30:00Z", + } + }, + ) + self.seed_neighbor_farm() + + def seed_neighbor_farm(self) -> None: + neighbor_location = self.create_complete_location( + lat=35.706000, + lon=51.406000, + boundary=square_boundary(35.706000, 51.406000, delta=0.008), + clay_values=(19.0, 16.5, 13.0), + nitrogen_values=(12.0, 10.0, 7.0), + ) + self.seed_weather_forecasts( + neighbor_location, + start=self.forecast_start, + days=7, + temperature_base=24.0, + et0_base=3.8, + ) + neighbor_sensor = SensorData.objects.create( + farm_uuid=uuid.uuid4(), + center_location=neighbor_location, + weather_forecast=neighbor_location.weather_forecasts.order_by("forecast_date").first(), + irrigation_method_id=self.irrigation_method["id"], + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 38.5, + "soil_temperature": 25.0, + "soil_ph": 6.7, + "electrical_conductivity": 1.0, + "nitrogen": 16.0, + "timestamp": "2026-04-10T06:35:00Z", + } + }, + ) + neighbor_sensor.plants.set([self.primary_plant["id"]]) + + def test_reporting_endpoints_read_from_persisted_farm_context(self) -> None: + soil_response = self.client.get( + "/api/soil-data/", + data={"lat": f"{self.primary_lat:.6f}", "lon": f"{self.primary_lon:.6f}"}, + ) + self.assertEqual(soil_response.status_code, 200) + self.assertEqual(soil_response.json()["data"]["source"], "database") + self.assertIn("satellite_snapshots", soil_response.json()["data"]) + + weather_response = self.client.post( + "/api/weather/farm-card/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(weather_response.status_code, 200) + self.assertEqual(weather_response.json()["data"]["condition"], "صاف") + + with patch( + "weather.water_need_prediction.get_water_need_prediction_insight", + return_value={ + "summary": "Water demand is moderate for the next week.", + "irrigation_outlook": "Increase slowly.", + "recommended_action": "Keep early morning irrigation.", + "risk_note": "Watch evapotranspiration after day 4.", + "confidence": 0.88, + "knowledge_base": "water_need_prediction", + "tone_file": "config/tones/water_need_prediction_tone.txt", + "raw_response": "{\"summary\": \"ok\"}", + }, + ): + water_need_response = self.client.post( + "/api/weather/water-need-prediction/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(water_need_response.status_code, 200) + self.assertGreater(water_need_response.json()["data"]["totalNext7Days"], 0) + self.assertEqual( + water_need_response.json()["data"]["knowledge_base"], + "water_need_prediction", + ) + + economy_response = self.client.post( + "/api/economy/overview/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(economy_response.status_code, 200) + self.assertEqual(economy_response.json()["data"]["farm_uuid"], str(self.farm_uuid)) + + heatmap_response = self.client.post( + "/api/soile/moisture-heatmap/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(heatmap_response.status_code, 200) + self.assertGreater(len(heatmap_response.json()["data"]["grid_cells"]), 0) + self.assertGreaterEqual(len(heatmap_response.json()["data"]["sensor_points"]), 1) + + soil_health_response = self.client.post( + "/api/soile/health-summary/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(soil_health_response.status_code, 200) + self.assertIn("healthScore", soil_health_response.json()["data"]) + + with patch( + "soile.services.get_soil_anomaly_insight", + return_value={ + "interpretation": { + "summary": "No critical anomaly detected.", + "recommended_action": "Continue monitoring.", + }, + "knowledge_base": "soil_anomaly", + "raw_response": "{\"status\": \"ok\"}", + }, + ): + anomaly_response = self.client.post( + "/api/soile/anomaly-detection/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(anomaly_response.status_code, 200) + self.assertEqual(anomaly_response.json()["data"]["knowledge_base"], "soil_anomaly") + + ndvi_response = self.client.post( + "/api/soil-data/ndvi-health/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(ndvi_response.status_code, 200) + self.assertEqual(ndvi_response.json()["data"]["vegetation_health_class"], "Healthy") + + broken_simulation_service = SimpleNamespace( + get_water_stress=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulation offline")) + ) + crop_simulation_app = apps.get_app_config("crop_simulation") + with patch.object( + crop_simulation_app, + "get_water_stress_service", + return_value=broken_simulation_service, + ): + water_stress_response = self.client.post( + "/api/irrigation/water-stress/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(water_stress_response.status_code, 500) + + def test_ai_assistant_and_recommendation_endpoints_use_farm_context(self) -> None: + with ExitStack() as stack: + stack.enter_context( + patch("rag.config.load_rag_config", return_value=SimpleNamespace()) + ) + stack.enter_context( + patch( + "rag.views.chat_rag_stream", + return_value=iter(["Farm looks stable. ", "Moisture is acceptable."]), + ) + ) + stack.enter_context( + patch( + "rag.services.irrigation.get_irrigation_recommendation", + return_value={ + "sections": [ + { + "type": "recommendation", + "title": "برنامه آبیاری بهینه", + "content": "هفته ای 3 نوبت آبیاری انجام شود.", + } + ], + }, + ) + ) + stack.enter_context( + patch( + "rag.services.fertilization.get_fertilization_recommendation", + return_value={ + "sections": [ + { + "type": "recommendation", + "title": "برنامه کودهی بهینه", + "fertilizerType": "15-5-30", + "amount": "60 kg", + } + ], + }, + ) + ) + stack.enter_context( + patch( + "pest_disease.views.get_pest_disease_detection", + return_value={ + "diagnosis": "low risk leaf stress", + "confidence": 0.81, + }, + ) + ) + stack.enter_context( + patch( + "pest_disease.views.get_pest_disease_risk", + return_value={ + "riskLevel": "medium", + "topRisk": "powdery mildew", + }, + ) + ) + + chat_response = self.client.post( + "/api/rag/chat/", + data={ + "farm_uuid": str(self.farm_uuid), + "query": "Give me a short farm update", + "history": [], + }, + format="json", + ) + self.assertEqual(chat_response.status_code, 200) + streamed_text = b"".join(chat_response.streaming_content).decode("utf-8") + self.assertIn("Moisture is acceptable", streamed_text) + + irrigation_recommend_response = self.client.post( + "/api/irrigation/recommend/", + data={ + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "growth_stage": "flowering", + "irrigation_method_name": "Analytics Drip", + }, + format="json", + ) + self.assertEqual(irrigation_recommend_response.status_code, 200) + self.assertEqual( + irrigation_recommend_response.json()["data"]["sections"][0]["type"], + "recommendation", + ) + + fertilization_recommend_response = self.client.post( + "/api/fertilization/recommend/", + data={ + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "growth_stage": "flowering", + }, + format="json", + ) + self.assertEqual(fertilization_recommend_response.status_code, 200) + self.assertEqual( + fertilization_recommend_response.json()["data"]["sections"][0]["fertilizerType"], + "15-5-30", + ) + + pest_detect_response = self.client.post( + "/api/pest-disease/detect/", + data={ + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "query": "Check leaf condition", + "image_urls": ["https://example.com/leaf.jpg"], + }, + format="json", + ) + self.assertEqual(pest_detect_response.status_code, 200) + self.assertEqual(pest_detect_response.json()["data"]["confidence"], 0.81) + + pest_risk_response = self.client.post( + "/api/pest-disease/risk/", + data={ + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "growth_stage": "flowering", + }, + format="json", + ) + self.assertEqual(pest_risk_response.status_code, 200) + + def test_alert_and_crop_simulation_endpoints_persist_records(self) -> None: + def tracker_stub(*, farm_uuid: str, query: str | None = None): + from farm_alerts.services import _save_notifications, _serialize_notification + + saved = _save_notifications( + farm_uuid=str(farm_uuid), + endpoint=FarmAlertNotification.ENDPOINT_TRACKER, + notifications=[ + { + "level": FarmAlertNotification.LEVEL_WARNING, + "title": "Moisture drift", + "message": "Moisture dropped below the target band.", + "suggested_action": "Review the next irrigation cycle.", + "source_alert_id": "soil-moisture:1", + "source_metric_type": "soil_moisture", + } + ], + ) + return { + "headline": "Tracker result", + "overview": "One warning was recorded.", + "status_level": "warning", + "query": query, + "notifications": [_serialize_notification(item) for item in saved], + } + + def timeline_stub(*, farm_uuid: str, query: str | None = None): + from farm_alerts.services import _save_notifications, _serialize_notification + + saved = _save_notifications( + farm_uuid=str(farm_uuid), + endpoint=FarmAlertNotification.ENDPOINT_TIMELINE, + notifications=[ + { + "level": FarmAlertNotification.LEVEL_INFO, + "title": "Irrigation completed", + "message": "The latest drip cycle finished successfully.", + "suggested_action": "Recheck moisture after sunrise.", + "source_alert_id": "irrigation:1", + "source_metric_type": "irrigation", + } + ], + ) + return { + "headline": "Timeline result", + "overview": "One event was stored.", + "query": query, + "timeline": [ + { + "timestamp": "2026-04-10T05:30:00Z", + "level": "info", + "title": "Irrigation completed", + "description": "Cycle finished.", + "source_alert_id": "irrigation:1", + "source_metric_type": "irrigation", + } + ], + "notifications": [_serialize_notification(item) for item in saved], + } + + with patch("farm_alerts.views.get_farm_alerts_tracker", side_effect=tracker_stub): + tracker_response = self.client.post( + "/api/farm-alerts/tracker/", + data={"farm_uuid": str(self.farm_uuid), "query": "status"}, + format="json", + ) + self.assertEqual(tracker_response.status_code, 200) + + with patch("farm_alerts.views.get_farm_alerts_timeline", side_effect=timeline_stub): + timeline_response = self.client.post( + "/api/farm-alerts/timeline/", + data={"farm_uuid": str(self.farm_uuid)}, + format="json", + ) + self.assertEqual(timeline_response.status_code, 200) + self.assertEqual( + FarmAlertNotification.objects.filter(farm_uuid=self.farm_uuid).count(), + 2, + ) + + current_chart_service = SimpleNamespace( + simulate=lambda **_kwargs: { + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "engine": "stub", + "model_name": "integration-model", + "scenario_id": None, + "simulation_warning": "", + "categories": ["day1", "day2"], + "series": [{"name": "LAI", "data": [0.8, 1.1]}], + "summary": {"expectedTrend": "up"}, + "current_state": {"soilMoisture": 46.0}, + "metrics": {"yieldEstimate": 8.2}, + "daily_output": [{"day": "2026-04-10", "lai": 0.8}], + } + ) + harvest_service = SimpleNamespace( + get_harvest_prediction=lambda **_kwargs: { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "description": "Expected harvest window", + "optimalWindowStart": "2026-07-10", + "optimalWindowEnd": "2026-07-20", + "gddDetails": {"current": 420, "target": 1220}, + } + ) + yield_service = SimpleNamespace( + get_yield_prediction=lambda **_kwargs: { + "farm_uuid": str(self.farm_uuid), + "plant_name": "Tomato", + "predictedYieldTons": 8.4, + "predictedYieldRaw": 8400.0, + "unit": "t/ha", + "sourceUnit": "kg/ha", + "simulationEngine": "stub", + "simulationModel": "integration-model", + "scenarioId": None, + "simulationWarning": "", + "supportingMetrics": {"biomass": 12.1}, + } + ) + crop_simulation_app = apps.get_app_config("crop_simulation") + with ( + patch.object( + crop_simulation_app, + "get_current_farm_chart_simulator", + return_value=current_chart_service, + ), + patch.object( + crop_simulation_app, + "get_harvest_prediction_service", + return_value=harvest_service, + ), + patch.object( + crop_simulation_app, + "get_yield_prediction_service", + return_value=yield_service, + ), + ): + current_chart_response = self.client.post( + "/api/crop-simulation/current-farm-chart/", + data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"}, + format="json", + ) + harvest_response = self.client.post( + "/api/crop-simulation/harvest-prediction/", + data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"}, + format="json", + ) + yield_response = self.client.post( + "/api/crop-simulation/yield-prediction/", + data={"farm_uuid": str(self.farm_uuid), "plant_name": "Tomato"}, + format="json", + ) + self.assertEqual(current_chart_response.status_code, 200) + self.assertEqual(harvest_response.status_code, 200) + self.assertEqual(yield_response.status_code, 200) + + task_state: dict[str, object] = {} + + def growth_delay_stub(payload): + serializable_payload = { + **payload, + "farm_uuid": str(payload["farm_uuid"]) if payload.get("farm_uuid") else None, + } + scenario = SimulationScenario.objects.create( + name=f"integration-{payload['plant_name']}", + scenario_type=SimulationScenario.ScenarioType.SINGLE, + status=SimulationScenario.Status.SUCCESS, + input_payload=serializable_payload, + result_payload={"engine": "stub"}, + ) + SimulationRun.objects.create( + scenario=scenario, + run_key="primary", + label=payload["plant_name"], + status=SimulationScenario.Status.SUCCESS, + weather_payload=payload.get("weather", []), + soil_payload=payload.get("soil_parameters", {}), + result_payload={"summary": "ok"}, + ) + task_id = f"growth-task-{scenario.id}" + task_state["task_id"] = task_id + task_state["result"] = { + "plant_name": payload["plant_name"], + "dynamic_parameters": payload["dynamic_parameters"], + "engine": "stub", + "model_name": "integration-model", + "scenario_id": scenario.id, + "simulation_warning": "", + "summary_metrics": {"yield_estimate": 8.4}, + "stage_timeline": [ + { + "order": 1, + "stage_code": "VEG", + "stage_name": "Vegetative", + "start_date": "2026-04-10", + "end_date": "2026-05-05", + "days_count": 25, + "metrics": {"LAI": {"start": 0.8, "end": 1.5, "min": 0.8, "max": 1.5, "avg": 1.15}}, + }, + { + "order": 2, + "stage_code": "FLOW", + "stage_name": "Flowering", + "start_date": "2026-05-06", + "end_date": "2026-06-01", + "days_count": 26, + "metrics": {"LAI": {"start": 1.6, "end": 2.2, "min": 1.6, "max": 2.2, "avg": 1.9}}, + }, + ], + "daily_records_count": 51, + "default_page_size": payload.get("page_size", 2), + } + return SimpleNamespace(id=task_id) + + with patch("crop_simulation.views.run_growth_simulation_task.delay", side_effect=growth_delay_stub): + growth_response = self.client.post( + "/api/crop-simulation/growth/", + data={ + "plant_name": "Tomato", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm_uuid), + "page_size": 1, + }, + format="json", + ) + self.assertEqual(growth_response.status_code, 202) + + with patch( + "crop_simulation.views._get_async_result", + return_value=FakeAsyncResult(state="SUCCESS", result=task_state["result"]), + ): + growth_status_response = self.client.get( + f"/api/crop-simulation/growth/{task_state['task_id']}/status/?page=1&page_size=1" + ) + self.assertEqual(growth_status_response.status_code, 200) + self.assertEqual( + SimulationScenario.objects.filter(name="integration-Tomato").count(), + 1, + ) + self.assertEqual(SimulationRun.objects.count(), 1) + self.assertEqual( + growth_status_response.json()["data"]["result"]["pagination"]["page_size"], + 1, + ) diff --git a/Modules/Ai/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md b/Modules/Ai/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md new file mode 100644 index 0000000..56f0165 --- /dev/null +++ b/Modules/Ai/irrigation/IRRIGATION_RECOMMENDATION_API_FIELDS.md @@ -0,0 +1,363 @@ +# Irrigation Recommendation API Fields + +این فایل فقط فیلدهای API مربوط به `POST /api/irrigation/recommend/` را توضیح می‌دهد. + +## Endpoint + +`POST /api/irrigation/recommend/` + +## کاربرد + +این endpoint برای تولید recommendation آبیاری استفاده می‌شود و خروجی آن با UI فعلی صفحه +`src/views/dashboards/farm/smartIrrigation/SmartIrrigationRecommendation.tsx` +هماهنگ شده است. + +## ساختار کلی پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan": {}, + "water_balance": {}, + "timeline": [], + "sections": [] + } +} +``` + +## Request + +### حداقل payload پیشنهادی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_type": "آبیاری قطره‌ای" +} +``` + +### فیلدهای Request + +### `farm_uuid` +- نوع: `string` +- اجباری: بله +- توضیح: شناسه یکتای مزرعه. + +### `sensor_uuid` +- نوع: `string` +- اجباری: خیر +- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود. + +### `plant_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام گیاه هدف برای تولید توصیه. اگر ارسال نشود، سیستم در صورت امکان گیاه ثبت‌شده روی مزرعه را استفاده می‌کند. + +### `growth_stage` +- نوع: `string` +- اجباری: خیر +- توضیح: مرحله رشد گیاه مثل `رویشی`، `گلدهی` یا `میوه‌دهی`. + +### `irrigation_type` +- نوع: `string` +- اجباری: خیر +- توضیح: نوع یا نام روش آبیاری مورد نظر فرانت. این فیلد برای UI فعلی پیشنهاد می‌شود. + +### `irrigation_method_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام روش آبیاری. این فیلد با `irrigation_type` هم‌ارز است و در بک‌اند به همان ورودی نهایی نرمال می‌شود. + + +## Response + +## فیلدهای لایه اول Response + +### `code` +- نوع: `number` +- توضیح: کد وضعیت پاسخ در قالب استاندارد API پروژه. + +### `msg` +- نوع: `string` +- توضیح: پیام وضعیت پاسخ. در حالت موفق معمولاً `success` است. + +### `data` +- نوع: `object` +- توضیح: بدنه اصلی recommendation آبیاری. + +## فیلدهای `data` + +### `plan` +- نوع: `object` +- توضیح: خلاصه اصلی recommendation برای نمایش در کارت بالای UI. + +### `water_balance` +- نوع: `object` +- توضیح: تراز آب و خروجی محاسبات روزانه FAO-56. + +### `timeline` +- نوع: `array` +- توضیح: مراحل اجرایی recommendation برای Stepper. + +### `sections` +- نوع: `array` +- توضیح: نکات تکمیلی و هشدارها. در UI فعلی فقط `warning` و `tip` مصرف می‌شوند. + +## فیلدهای `data.plan` + +```json +{ + "frequencyPerWeek": 4, + "durationMinutes": 38, + "bestTimeOfDay": "05:30 تا 08:00 صبح", + "moistureLevel": 72, + "warning": "در ساعات گرم روز آبیاری انجام نشود" +} +``` + +### `frequencyPerWeek` +- نوع: `number` +- توضیح: تعداد نوبت آبیاری در هفته. + +### `durationMinutes` +- نوع: `number` +- توضیح: مدت هر نوبت آبیاری بر حسب دقیقه. + +### `bestTimeOfDay` +- نوع: `string` +- توضیح: بهترین بازه زمانی اجرای آبیاری. + +### `moistureLevel` +- نوع: `number` +- توضیح: سطح رطوبت فعلی یا هدف خاک برای نمایش در gauge. + +### `warning` +- نوع: `string` +- توضیح: هشدار اصلی recommendation. + +## فیلدهای `data.water_balance` + +```json +{ + "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" + } + ] +} +``` + +### `active_kc` +- نوع: `number` +- توضیح: ضریب Kc فعال برای مرحله رشد فعلی. + +### `crop_profile` +- نوع: `object` +- توضیح: پروفایل Kc گیاه در مراحل مختلف. + +### `daily` +- نوع: `array` +- توضیح: داده‌های روزانه مورد استفاده در جدول یا نمودار تراز آب. + +## فیلدهای `data.water_balance.crop_profile` + +### `kc_initial` +- نوع: `number` +- توضیح: Kc مرحله ابتدایی رشد. + +### `kc_mid` +- نوع: `number` +- توضیح: Kc مرحله میانی رشد. + +### `kc_end` +- نوع: `number` +- توضیح: Kc مرحله پایانی رشد. + +## فیلدهای هر آیتم در `data.water_balance.daily[]` + +### `forecast_date` +- نوع: `string` +- توضیح: تاریخ پیش‌بینی. + +### `et0_mm` +- نوع: `number` +- توضیح: تبخیر و تعرق مرجع روزانه. + +### `etc_mm` +- نوع: `number` +- توضیح: تبخیر و تعرق گیاه. + +### `effective_rainfall_mm` +- نوع: `number` +- توضیح: بارش مؤثر محاسبه‌شده. + +### `gross_irrigation_mm` +- نوع: `number` +- توضیح: مقدار آبیاری ناخالص پیشنهادی برای آن روز. + +### `irrigation_timing` +- نوع: `string` +- توضیح: زمان پیشنهادی اجرای آبیاری برای آن روز. + +## فیلدهای `data.timeline` + +```json +[ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود" + } +] +``` + +### `step_number` +- نوع: `number` +- توضیح: شماره مرحله. + +### `title` +- نوع: `string` +- توضیح: عنوان مرحله. + +### `description` +- نوع: `string` +- توضیح: توضیح اجرایی مرحله. + +## فیلدهای `data.sections` + +```json +[ + { + "title": "هشدار تبخیر بالا", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": "در ساعات گرم روز آبیاری انجام نشود" + }, + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } +] +``` + +### `title` +- نوع: `string` +- توضیح: عنوان کارت. + +### `icon` +- نوع: `string` +- توضیح: نام آیکون مورد استفاده در UI. + +### `type` +- نوع: `string` +- توضیح: نوع سکشن. در UI فعلی فقط این مقادیر مصرف می‌شوند: + - `warning` + - `tip` + +### `content` +- نوع: `string` +- توضیح: متن هشدار یا نکته. + +## حداقل پاسخ قابل استفاده برای UI فعلی + +```json +{ + "code": 200, + "msg": "success", + "data": { + "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": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } + ] + } +} +``` + +## فیلدهایی که فرانت فعلی لازم ندارد + +فیلدهای زیر برای UI فعلی recommendation لازم نیستند و نباید به عنوان dependency فرانت در نظر گرفته شوند: + +- `raw_response` +- `status` +- `generated_at` +- `recommendation_title` +- `recommendation_subtitle` +- `final_verdict` +- `primary_method` +- `usage_summary` +- `alternative_plans` +- `sections[].type = schedule` +- `sections[].type = method` + +## نمونه cURL + +```bash +curl -s -X POST "http://localhost:8000/api/irrigation/recommend/" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d '{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_type": "آبیاری قطره‌ای" + }' +``` diff --git a/Modules/Ai/irrigation/__init__.py b/Modules/Ai/irrigation/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/irrigation/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/irrigation/admin.py b/Modules/Ai/irrigation/admin.py new file mode 100644 index 0000000..1c3cede --- /dev/null +++ b/Modules/Ai/irrigation/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import IrrigationMethod + + +@admin.register(IrrigationMethod) +class IrrigationMethodAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "category", + "water_efficiency_percent", + "soil_type", + "climate_suitability", + "created_at", + ) + list_filter = ("category", "climate_suitability") + search_fields = ("name", "category") + readonly_fields = ("created_at", "updated_at") diff --git a/Modules/Ai/irrigation/apps.py b/Modules/Ai/irrigation/apps.py new file mode 100644 index 0000000..1c22ec0 --- /dev/null +++ b/Modules/Ai/irrigation/apps.py @@ -0,0 +1,69 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class IrrigationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "irrigation" + verbose_name = "Irrigation" + tone_file = "config/tones/irrigation_tone.txt" + + @cached_property + def optimizer_defaults(self): + return { + "simulation_model": "Wofost81_NWLP_CWB_CNB", + "validity_days": 3, + "minimum_event_mm": 4.0, + "significant_rain_threshold_mm": 4.0, + "stage_targets": { + "initial": 65, + "vegetative": 70, + "flowering": 75, + "fruiting": 68, + }, + "strategy_profiles": [ + { + "code": "conservative", + "label": "آبیاری محافظه کارانه", + "multiplier": 0.82, + "frequency_factor": 0.85, + "event_count": 2, + }, + { + "code": "balanced", + "label": "آبیاری متعادل", + "multiplier": 1.0, + "frequency_factor": 1.0, + "event_count": 3, + }, + { + "code": "protective", + "label": "آبیاری حمایتی", + "multiplier": 1.12, + "frequency_factor": 1.2, + "event_count": 4, + }, + ], + } + + def get_optimizer_defaults(self): + return self.optimizer_defaults + + @cached_property + def water_stress_service(self): + from .indicators import WaterStressService + + return WaterStressService() + + def get_water_stress_service(self): + return self.water_stress_service + + @cached_property + def free_text_plan_parser_service(self): + from rag.services.irrigation_plan_parser import IrrigationPlanParserService + + return IrrigationPlanParserService() + + def get_free_text_plan_parser_service(self): + return self.free_text_plan_parser_service diff --git a/Modules/Ai/irrigation/evapotranspiration.py b/Modules/Ai/irrigation/evapotranspiration.py new file mode 100644 index 0000000..6c87ea7 --- /dev/null +++ b/Modules/Ai/irrigation/evapotranspiration.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from math import acos, cos, exp, pi, sin, sqrt, tan +from typing import Any + + +DEFAULT_CROP_PROFILE = { + "kc_initial": 0.6, + "kc_mid": 1.05, + "kc_end": 0.8, + "growth_stage_duration": { + "initial": 20, + "mid": 30, + "late": 25, + }, + "current_stage": "mid", +} + +DEFAULT_STAGE_KC = { + "initial": "kc_initial", + "development": "kc_mid", + "mid": "kc_mid", + "late": "kc_end", + "end": "kc_end", +} + + +@dataclass +class DailyWaterNeed: + forecast_date: str + et0_mm: float + etc_mm: float + effective_rainfall_mm: float + net_irrigation_mm: float + gross_irrigation_mm: float + kc: float + irrigation_timing: str + + +def _saturation_vapor_pressure(temperature_c: float) -> float: + return 0.6108 * exp((17.27 * temperature_c) / (temperature_c + 237.3)) + + +def _slope_vapor_pressure_curve(temperature_c: float) -> float: + es = _saturation_vapor_pressure(temperature_c) + return (4098 * es) / ((temperature_c + 237.3) ** 2) + + +def _psychrometric_constant(elevation_m: float = 0.0) -> float: + pressure = 101.3 * (((293.0 - (0.0065 * elevation_m)) / 293.0) ** 5.26) + return 0.000665 * pressure + + +def _extraterrestrial_radiation(day_of_year: int, latitude_deg: float) -> float: + latitude_rad = latitude_deg * pi / 180.0 + dr = 1 + (0.033 * cos((2 * pi / 365) * day_of_year)) + solar_declination = 0.409 * sin(((2 * pi / 365) * day_of_year) - 1.39) + ws = acos(max(-1.0, min(1.0, -tan(latitude_rad) * tan(solar_declination)))) + return ( + (24 * 60 / pi) + * 0.0820 + * dr + * ( + (ws * sin(latitude_rad) * sin(solar_declination)) + + (cos(latitude_rad) * cos(solar_declination) * sin(ws)) + ) + ) + + +def _estimate_net_radiation( + forecast: Any, + latitude_deg: float, + elevation_m: float = 0.0, +) -> float: + day_of_year = forecast.forecast_date.timetuple().tm_yday + ra = _extraterrestrial_radiation(day_of_year, latitude_deg) + temp_max = float(getattr(forecast, "temperature_max", None) or getattr(forecast, "temperature_mean", 25.0)) + temp_min = float(getattr(forecast, "temperature_min", None) or getattr(forecast, "temperature_mean", 15.0)) + rh_mean = float(getattr(forecast, "humidity_mean", None) or 50.0) + + temp_range = max(temp_max - temp_min, 0.1) + rs = 0.16 * sqrt(temp_range) * ra + rso = (0.75 + (2e-5 * elevation_m)) * ra + ea = (rh_mean / 100.0) * _saturation_vapor_pressure((temp_max + temp_min) / 2.0) + rns = (1 - 0.23) * rs + rs_rso_ratio = min(rs / rso, 1.0) if rso else 0.0 + rnl = 4.903e-9 * ( + (((temp_max + 273.16) ** 4) + ((temp_min + 273.16) ** 4)) / 2 + ) * (0.34 - (0.14 * sqrt(max(ea, 0.0)))) * ((1.35 * rs_rso_ratio) - 0.35) + return max(rns - rnl, 0.0) + + +def calculate_daily_et0(forecast: Any, latitude_deg: float, elevation_m: float = 0.0) -> float: + temp_mean = float(getattr(forecast, "temperature_mean", None) or 20.0) + temp_max = float(getattr(forecast, "temperature_max", None) or temp_mean + 3.0) + temp_min = float(getattr(forecast, "temperature_min", None) or temp_mean - 3.0) + wind_speed_kmh = float(getattr(forecast, "wind_speed_max", None) or 7.2) + wind_speed_ms = wind_speed_kmh / 3.6 + rh_mean = float(getattr(forecast, "humidity_mean", None) or 50.0) + + delta = _slope_vapor_pressure_curve(temp_mean) + gamma = _psychrometric_constant(elevation_m) + rn = _estimate_net_radiation(forecast, latitude_deg=latitude_deg, elevation_m=elevation_m) + es = (_saturation_vapor_pressure(temp_max) + _saturation_vapor_pressure(temp_min)) / 2.0 + ea = (rh_mean / 100.0) * _saturation_vapor_pressure(temp_mean) + g = 0.0 + + numerator = (0.408 * delta * (rn - g)) + (gamma * (900.0 / (temp_mean + 273.0)) * wind_speed_ms * (es - ea)) + denominator = delta + (gamma * (1 + (0.34 * wind_speed_ms))) + if denominator == 0: + return 0.0 + return round(max(numerator / denominator, 0.0), 3) + + +def resolve_crop_profile(plant: Any | None, growth_stage: str | None = None) -> dict: + profile = getattr(plant, "irrigation_profile", None) or {} + merged = {**DEFAULT_CROP_PROFILE, **profile} + durations = {**DEFAULT_CROP_PROFILE["growth_stage_duration"], **profile.get("growth_stage_duration", {})} + merged["growth_stage_duration"] = durations + merged["current_stage"] = (growth_stage or profile.get("current_stage") or DEFAULT_CROP_PROFILE["current_stage"]).lower() + return merged + + +def resolve_kc(profile: dict, growth_stage: str | None = None) -> float: + stage = (growth_stage or profile.get("current_stage") or "mid").lower() + kc_key = DEFAULT_STAGE_KC.get(stage, "kc_mid") + return float(profile.get(kc_key, DEFAULT_CROP_PROFILE[kc_key])) + + +def effective_rainfall(precipitation_mm: float, etc_mm: float) -> float: + if precipitation_mm <= 0: + return 0.0 + return round(min(precipitation_mm * 0.8, etc_mm), 3) + + +def recommend_irrigation_timing(forecast: Any) -> str: + temp_mean = float(getattr(forecast, "temperature_mean", None) or 20.0) + wind_speed = float(getattr(forecast, "wind_speed_max", None) or 0.0) + if temp_mean >= 30 or wind_speed >= 20: + return "اوایل صبح" + if temp_mean <= 18: + return "اواخر صبح" + return "اوایل صبح یا نزدیک غروب" + + +def calculate_daily_water_need( + forecast: Any, + latitude_deg: float, + crop_profile: dict, + growth_stage: str | None = None, + irrigation_efficiency_percent: float | None = None, + elevation_m: float = 0.0, +) -> DailyWaterNeed: + et0_mm = calculate_daily_et0(forecast, latitude_deg=latitude_deg, elevation_m=elevation_m) + kc = resolve_kc(crop_profile, growth_stage=growth_stage) + etc_mm = round(kc * et0_mm, 3) + rainfall_mm = float(getattr(forecast, "precipitation", None) or 0.0) + effective_rain_mm = effective_rainfall(rainfall_mm, etc_mm) + net_irrigation_mm = round(max(etc_mm - effective_rain_mm, 0.0), 3) + efficiency = max((irrigation_efficiency_percent or 100.0) / 100.0, 0.01) + gross_irrigation_mm = round(net_irrigation_mm / efficiency, 3) + return DailyWaterNeed( + forecast_date=forecast.forecast_date.isoformat() if isinstance(forecast.forecast_date, date) else str(forecast.forecast_date), + et0_mm=et0_mm, + etc_mm=etc_mm, + effective_rainfall_mm=effective_rain_mm, + net_irrigation_mm=net_irrigation_mm, + gross_irrigation_mm=gross_irrigation_mm, + kc=round(kc, 3), + irrigation_timing=recommend_irrigation_timing(forecast), + ) + + +def calculate_forecast_water_needs( + forecasts: list[Any], + latitude_deg: float, + crop_profile: dict, + growth_stage: str | None = None, + irrigation_efficiency_percent: float | None = None, + elevation_m: float = 0.0, +) -> list[dict]: + return [ + calculate_daily_water_need( + forecast=forecast, + latitude_deg=latitude_deg, + crop_profile=crop_profile, + growth_stage=growth_stage, + irrigation_efficiency_percent=irrigation_efficiency_percent, + elevation_m=elevation_m, + ).__dict__ + for forecast in forecasts + ] diff --git a/Modules/Ai/irrigation/indicators.py b/Modules/Ai/irrigation/indicators.py new file mode 100644 index 0000000..9ab9142 --- /dev/null +++ b/Modules/Ai/irrigation/indicators.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from django.apps import apps + +from farm_data.models import SensorData + +class WaterStressService: + def get_water_stress( + self, + *, + farm_uuid: str, + plant_name: str | None = None, + irrigation_recommendation: dict | None = None, + fertilization_recommendation: dict | None = None, + ) -> dict[str, Any]: + sensor = SensorData.objects.filter(farm_uuid=farm_uuid).first() + if sensor is None: + raise ValueError("Farm not found.") + simulation_service = apps.get_app_config("crop_simulation").get_water_stress_service() + try: + return simulation_service.get_water_stress( + farm_uuid=str(sensor.farm_uuid), + plant_name=plant_name, + irrigation_recommendation=irrigation_recommendation, + fertilization_recommendation=fertilization_recommendation, + ) + except Exception as exc: + raise RuntimeError( + f"Water stress simulation failed for farm {sensor.farm_uuid}: {exc}" + ) from exc diff --git a/Modules/Ai/irrigation/management/__init__.py b/Modules/Ai/irrigation/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/irrigation/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/irrigation/management/commands/__init__.py b/Modules/Ai/irrigation/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/irrigation/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/irrigation/management/commands/seed_irrigation_methods.py b/Modules/Ai/irrigation/management/commands/seed_irrigation_methods.py new file mode 100644 index 0000000..a8ca7a8 --- /dev/null +++ b/Modules/Ai/irrigation/management/commands/seed_irrigation_methods.py @@ -0,0 +1,100 @@ +""" +Management command to seed initial irrigation methods. +Run: python manage.py seed_irrigation_methods +""" + +from django.core.management.base import BaseCommand + +from irrigation.models import IrrigationMethod + + +INITIAL_METHODS = [ + { + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آب با دبی کم و به‌صورت قطره‌ای مستقیماً به ریشه گیاه رسانده می‌شود. مناسب‌ترین روش برای مناطق خشک و کم‌آب.", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "تمام انواع خاک", + "climate_suitability": "گرم و خشک", + }, + { + "name": "آبیاری بارانی", + "category": "تحت فشار", + "description": "آب تحت فشار از طریق آبپاش‌ها به‌صورت قطرات ریز مانند باران پخش می‌شود.", + "water_efficiency_percent": 75.0, + "water_pressure_required": "۲-۴ اتمسفر", + "flow_rate": "۵-۲۰ لیتر در دقیقه", + "coverage_area": "۱۰-۳۰ متر شعاع پاشش", + "soil_type": "لومی، لومی شنی", + "climate_suitability": "معتدل، مرطوب", + }, + { + "name": "آبیاری سطحی (غرقابی)", + "category": "سطحی", + "description": "آب در سطح زمین جاری شده و به‌صورت ثقلی زمین را آبیاری می‌کند. ساده‌ترین و قدیمی‌ترین روش.", + "water_efficiency_percent": 50.0, + "water_pressure_required": "نیاز به فشار ندارد (ثقلی)", + "flow_rate": "متغیر بر اساس شیب زمین", + "coverage_area": "وابسته به اندازه کرت", + "soil_type": "رسی، لومی رسی", + "climate_suitability": "تمام اقلیم‌ها (مناطق پرآب)", + }, + { + "name": "آبیاری نشتی (تیپ)", + "category": "موضعی", + "description": "آب از طریق نوارهای تیپ با منافذ ریز به‌صورت نشتی به خاک رسانده می‌شود.", + "water_efficiency_percent": 85.0, + "water_pressure_required": "۰.۵-۱.۵ اتمسفر", + "flow_rate": "۱-۴ لیتر در ساعت به ازای هر متر", + "coverage_area": "ردیفی، مناسب زراعت", + "soil_type": "لومی، لومی شنی", + "climate_suitability": "گرم و خشک", + }, + { + "name": "آبیاری زیرسطحی", + "category": "موضعی", + "description": "لوله‌های آبیاری در زیر سطح خاک کار گذاشته شده و آب مستقیماً به منطقه ریشه می‌رسد.", + "water_efficiency_percent": 95.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۱-۴ لیتر در ساعت", + "coverage_area": "بسته به طراحی", + "soil_type": "لومی، لومی رسی", + "climate_suitability": "تمام اقلیم‌ها", + }, + { + "name": "آبیاری بابلر", + "category": "موضعی", + "description": "آب با دبی بیشتر از قطره‌ای ولی کمتر از بارانی، به‌صورت حبابی در پای درخت پخش می‌شود. مناسب درختان میوه.", + "water_efficiency_percent": 80.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۸-۶۰ لیتر در ساعت", + "coverage_area": "شعاع ۱-۲ متر اطراف درخت", + "soil_type": "لومی، لومی رسی", + "climate_suitability": "گرم و خشک", + }, +] + + +class Command(BaseCommand): + help = "Seed initial irrigation methods (6 common methods)" + + def handle(self, *args, **options): + created_count = 0 + for method_data in INITIAL_METHODS: + _, created = IrrigationMethod.objects.get_or_create( + name=method_data["name"], + defaults=method_data, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {method_data['name']}") + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new irrigation methods." + ) + ) diff --git a/Modules/Ai/irrigation/migrations/0001_initial.py b/Modules/Ai/irrigation/migrations/0001_initial.py new file mode 100644 index 0000000..725177f --- /dev/null +++ b/Modules/Ai/irrigation/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='IrrigationMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='نام روش آبیاری (قطره\u200cای، بارانی، سطحی و …)', max_length=255, unique=True)), + ('category', models.CharField(blank=True, help_text='نوع روش (موضعی، تحت فشار، سطحی)', max_length=255)), + ('description', models.TextField(blank=True, help_text='توضیحات کامل روش')), + ('water_efficiency_percent', models.FloatField(blank=True, help_text='راندمان مصرف آب (%)', null=True)), + ('water_pressure_required', models.CharField(blank=True, help_text='فشار مورد نیاز آب', max_length=255)), + ('flow_rate', models.CharField(blank=True, help_text='دبی یا میزان جریان آب', max_length=255)), + ('coverage_area', models.CharField(blank=True, help_text='مساحت قابل پوشش', max_length=255)), + ('soil_type', models.CharField(blank=True, help_text='نوع خاک مناسب', max_length=255)), + ('climate_suitability', models.CharField(blank=True, help_text='اقلیم مناسب', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'روش آبیاری', + 'verbose_name_plural': 'روش\u200cهای آبیاری', + 'ordering': ['name'], + }, + ), + ] diff --git a/Modules/Ai/irrigation/migrations/__init__.py b/Modules/Ai/irrigation/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/irrigation/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/irrigation/models.py b/Modules/Ai/irrigation/models.py new file mode 100644 index 0000000..4f9e743 --- /dev/null +++ b/Modules/Ai/irrigation/models.py @@ -0,0 +1,63 @@ +from django.db import models + + +class IrrigationMethod(models.Model): + """ + روش‌های آبیاری شامل مشخصات فنی. + """ + + name = models.CharField( + max_length=255, + unique=True, + db_index=True, + help_text="نام روش آبیاری (قطره‌ای، بارانی، سطحی و …)", + ) + category = models.CharField( + max_length=255, + blank=True, + help_text="نوع روش (موضعی، تحت فشار، سطحی)", + ) + description = models.TextField( + blank=True, + help_text="توضیحات کامل روش", + ) + water_efficiency_percent = models.FloatField( + null=True, + blank=True, + help_text="راندمان مصرف آب (%)", + ) + water_pressure_required = models.CharField( + max_length=255, + blank=True, + help_text="فشار مورد نیاز آب", + ) + flow_rate = models.CharField( + max_length=255, + blank=True, + help_text="دبی یا میزان جریان آب", + ) + coverage_area = models.CharField( + max_length=255, + blank=True, + help_text="مساحت قابل پوشش", + ) + soil_type = models.CharField( + max_length=255, + blank=True, + help_text="نوع خاک مناسب", + ) + climate_suitability = models.CharField( + max_length=255, + blank=True, + help_text="اقلیم مناسب", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + verbose_name = "روش آبیاری" + verbose_name_plural = "روش‌های آبیاری" + + def __str__(self): + return self.name diff --git a/Modules/Ai/irrigation/serializers.py b/Modules/Ai/irrigation/serializers.py new file mode 100644 index 0000000..dbfa870 --- /dev/null +++ b/Modules/Ai/irrigation/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .models import IrrigationMethod + + +class IrrigationMethodSerializer(serializers.ModelSerializer): + """سریالایزر خروجی / ورودی برای IrrigationMethod.""" + + class Meta: + model = IrrigationMethod + fields = [ + "id", + "name", + "category", + "description", + "water_efficiency_percent", + "water_pressure_required", + "flow_rate", + "coverage_area", + "soil_type", + "climate_suitability", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class IrrigationRecommendRequestSerializer(serializers.Serializer): + """سریالایزر ورودی برای درخواست توصیه آبیاری.""" + + farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه (اجباری)") + sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه") + irrigation_method_name = serializers.CharField( + required=False, allow_blank=True, help_text="نام روش آبیاری" + ) + query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری") + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + attrs["farm_uuid"] = farm_uuid + return attrs + + +class IrrigationPlanSerializer(serializers.Serializer): + """سریالایزر خروجی برای پلن توصیه آبیاری.""" + + frequencyPerWeek = serializers.IntegerField(help_text="تعداد دفعات آبیاری در هفته") + durationMinutes = serializers.IntegerField(help_text="مدت هر بار آبیاری به دقیقه") + bestTimeOfDay = serializers.CharField(help_text="بهترین زمان آبیاری") + moistureLevel = serializers.IntegerField(help_text="سطح رطوبت مطلوب خاک به درصد") + warning = serializers.CharField(help_text="هشدار یا توصیه مهم", allow_blank=True) + + +class IrrigationRecommendResponseSerializer(serializers.Serializer): + """سریالایزر خروجی برای ریسپانس توصیه آبیاری.""" + + code = serializers.IntegerField() + msg = serializers.CharField() + data = serializers.DictField(child=serializers.JSONField()) + + +class WaterStressRequestSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه") + sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه") + irrigation_recommendation = serializers.JSONField(required=False) + fertilization_recommendation = serializers.JSONField(required=False) + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + attrs["farm_uuid"] = farm_uuid + return attrs + + +class WaterStressResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + waterStressIndex = serializers.IntegerField() + level = serializers.CharField() + sourceMetric = serializers.JSONField() + + +class IrrigationPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="توضیح آزاد کاربر درباره برنامه آبیاری") + answers = serializers.JSONField(required=False, help_text="پاسخ های تکمیلی کاربر به سوالات مرحله قبل") + partial_plan = serializers.JSONField(required=False, help_text="داده استخراج شده مرحله قبل برای ادامه تکمیل") + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="شناسه مزرعه برای غنی سازی context") + + def validate(self, attrs): + message = (attrs.get("message") or "").strip() + answers = attrs.get("answers") + partial_plan = attrs.get("partial_plan") + if not message and not isinstance(answers, dict) and not isinstance(partial_plan, dict): + raise serializers.ValidationError( + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ) + return attrs + + +class PlanClarificationQuestionSerializer(serializers.Serializer): + id = serializers.CharField() + field = serializers.CharField() + question = serializers.CharField() + rationale = serializers.CharField(required=False, allow_blank=True) + + +class IrrigationPlanParserResponseSerializer(serializers.Serializer): + status = serializers.CharField() + status_fa = serializers.CharField() + summary = serializers.CharField() + missing_fields = serializers.ListField(child=serializers.CharField()) + questions = PlanClarificationQuestionSerializer(many=True) + collected_data = serializers.JSONField() + final_plan = serializers.JSONField(required=False, allow_null=True) diff --git a/Modules/Ai/irrigation/test_irrigation_recommend_api.py b/Modules/Ai/irrigation/test_irrigation_recommend_api.py new file mode 100644 index 0000000..e09b932 --- /dev/null +++ b/Modules/Ai/irrigation/test_irrigation_recommend_api.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from unittest.mock import patch + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + + +@override_settings(ROOT_URLCONF="irrigation.urls") +class IrrigationRecommendApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("rag.services.irrigation.get_irrigation_recommendation") + def test_recommend_api_returns_water_balance(self, mock_get_irrigation_recommendation): + mock_get_irrigation_recommendation.return_value = { + "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": [], + "sections": [], + "simulation_optimizer": {"engine": "crop_simulation_heuristic"}, + "selected_irrigation_method": {"name": "آبیاری قطره‌ای"}, + } + + response = self.client.post( + "/recommend/", + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره‌ای", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + data = response.json()["data"] + self.assertIn("water_balance", data) + self.assertEqual(data["water_balance"]["active_kc"], 0.93) + self.assertNotIn("simulation_optimizer", data) + self.assertNotIn("selected_irrigation_method", data) diff --git a/Modules/Ai/irrigation/test_water_stress_api.py b/Modules/Ai/irrigation/test_water_stress_api.py new file mode 100644 index 0000000..2777be6 --- /dev/null +++ b/Modules/Ai/irrigation/test_water_stress_api.py @@ -0,0 +1,94 @@ +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 irrigation.indicators import WaterStressService + + +@override_settings(ROOT_URLCONF="irrigation.urls") +class WaterStressApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("irrigation.views.apps.get_app_config") + def test_water_stress_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_stress=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "waterStressIndex": 12, + "level": "پایین", + "sourceMetric": {"soilMoisture": 46.0}, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_stress_service=lambda: mock_service + ) + + response = self.client.post( + "/water-stress/", + data={ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["waterStressIndex"], 12) + + +class WaterStressServiceTests(TestCase): + @patch("irrigation.indicators.apps.get_app_config") + @patch("irrigation.indicators.SensorData.objects.filter") + def test_service_uses_crop_simulation_water_stress(self, mock_filter, mock_get_app_config): + mock_filter.return_value.first.return_value = SimpleNamespace( + farm_uuid="550e8400-e29b-41d4-a716-446655440000", + soil_moisture=46.0, + ) + mock_service = SimpleNamespace( + get_water_stress=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "plant_name": "Tomato", + "waterStressIndex": 37, + "level": "متوسط", + "sourceMetric": {"engine": "crop_simulation"}, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_stress_service=lambda: mock_service + ) + + payload = WaterStressService().get_water_stress( + farm_uuid="550e8400-e29b-41d4-a716-446655440000" + ) + + self.assertEqual(payload["waterStressIndex"], 37) + self.assertEqual(payload["sourceMetric"]["engine"], "crop_simulation") + + @patch("irrigation.indicators.apps.get_app_config") + @patch("irrigation.indicators.SensorData.objects.filter") + def test_service_raises_when_crop_simulation_fails(self, mock_filter, mock_get_app_config): + mock_filter.return_value.first.return_value = SimpleNamespace( + farm_uuid="550e8400-e29b-41d4-a716-446655440000", + soil_moisture=46.0, + ) + + class BrokenService: + def get_water_stress(self, **_kwargs): + raise RuntimeError("simulation offline") + + mock_get_app_config.return_value = SimpleNamespace( + get_water_stress_service=lambda: BrokenService() + ) + + with self.assertRaises(RuntimeError): + WaterStressService().get_water_stress( + farm_uuid="550e8400-e29b-41d4-a716-446655440000" + ) diff --git a/Modules/Ai/irrigation/urls.py b/Modules/Ai/irrigation/urls.py new file mode 100644 index 0000000..5fb4a5f --- /dev/null +++ b/Modules/Ai/irrigation/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + IrrigationMethodDetailView, + IrrigationMethodListCreateView, + IrrigationPlanParserView, + IrrigationRecommendView, + WaterStressView, +) + +urlpatterns = [ + path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), + path("/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), + path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"), + path("plan-from-text/", IrrigationPlanParserView.as_view(), name="irrigation-plan-from-text"), + path("water-stress/", WaterStressView.as_view(), name="water-stress"), +] diff --git a/Modules/Ai/irrigation/views.py b/Modules/Ai/irrigation/views.py new file mode 100644 index 0000000..9d7e5ce --- /dev/null +++ b/Modules/Ai/irrigation/views.py @@ -0,0 +1,494 @@ +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import ( + build_envelope_serializer, + build_response, +) + +from .models import IrrigationMethod +from .serializers import ( + IrrigationMethodSerializer, + IrrigationPlanParserRequestSerializer, + IrrigationPlanParserResponseSerializer, + IrrigationRecommendRequestSerializer, + WaterStressRequestSerializer, + WaterStressResponseSerializer, +) + + +IrrigationMethodListResponseSerializer = build_envelope_serializer( + "IrrigationMethodListResponseSerializer", + IrrigationMethodSerializer, + many=True, +) +IrrigationMethodDetailResponseSerializer = build_envelope_serializer( + "IrrigationMethodDetailResponseSerializer", + IrrigationMethodSerializer, +) +IrrigationValidationErrorSerializer = build_envelope_serializer( + "IrrigationValidationErrorSerializer", + data_required=False, + allow_null=True, +) +IrrigationRecommendResponseSerializer = build_envelope_serializer( + "IrrigationRecommendResponseSerializer", + data_schema=None, +) +IrrigationPlanParserEnvelopeSerializer = build_envelope_serializer( + "IrrigationPlanParserEnvelopeSerializer", + IrrigationPlanParserResponseSerializer, +) +WaterStressEnvelopeSerializer = build_envelope_serializer( + "WaterStressEnvelopeSerializer", + WaterStressResponseSerializer, +) + +IRRIGATION_RECOMMENDATION_INTERNAL_KEYS = { + "raw_response", + "simulation_optimizer", + "selected_irrigation_method", +} + + +def _prepare_irrigation_recommendation_response(value): + if isinstance(value, dict): + cleaned = {} + for key, item in value.items(): + if key in IRRIGATION_RECOMMENDATION_INTERNAL_KEYS or item is None: + continue + normalized = _prepare_irrigation_recommendation_response(item) + if normalized is not None: + cleaned[key] = normalized + return cleaned + + if isinstance(value, list): + cleaned_items = [] + for item in value: + normalized = _prepare_irrigation_recommendation_response(item) + if normalized is not None: + cleaned_items.append(normalized) + return cleaned_items + + return value + + +class IrrigationMethodListCreateView(APIView): + """لیست تمام روش‌های آبیاری و ایجاد روش جدید.""" + + @extend_schema( + tags=["Irrigation"], + summary="لیست روش‌های آبیاری", + description="لیست تمام روش‌های آبیاری ذخیره‌شده را برمی‌گرداند.", + responses={ + 200: build_response( + IrrigationMethodListResponseSerializer, + "لیست روش‌های آبیاری ذخیره‌شده.", + ), + }, + ) + def get(self, request): + methods = IrrigationMethod.objects.all() + serializer = IrrigationMethodSerializer(methods, many=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="ایجاد روش آبیاری جدید", + description="یک روش آبیاری جدید ایجاد می‌کند.", + request=IrrigationMethodSerializer, + responses={ + 201: build_response( + IrrigationMethodDetailResponseSerializer, + "روش آبیاری جدید با موفقیت ایجاد شد.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و فشار مناسب", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "تمام انواع خاک", + "climate_suitability": "گرم و خشک", + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = IrrigationMethodSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 201, "msg": "success", "data": serializer.data}, + status=status.HTTP_201_CREATED, + ) + + +class IrrigationRecommendView(APIView): + """ + توصیه آبیاری به صورت مستقیم. + POST با farm_uuid، plant_name، growth_stage، irrigation_method_name. + اطلاعات گیاه از plant app و روش آبیاری از irrigation app دریافت می‌شود. + """ + + @extend_schema( + tags=["Irrigation Recommendation"], + summary="درخواست توصیه آبیاری", + description=( + "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و " + "توصیه آبیاری را مستقیم برمی‌گرداند. " + "اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود. " + "محاسبات ET₀ و ETc با مدل FAO-56 در بک‌اند انجام می‌شود و مدل زبانی فقط توضیح برنامه آبیاری را تولید می‌کند." + ), + request=IrrigationRecommendRequestSerializer, + responses={ + 200: build_response( + IrrigationRecommendResponseSerializer, + "توصیه آبیاری با موفقیت تولید شد.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "پارامتر ورودی نامعتبر است.", + ), + 500: build_response( + IrrigationValidationErrorSerializer, + "خطا در تولید توصیه آبیاری.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره‌ای", + }, + request_only=True, + ), + ], + ) + def post(self, request): + from rag.services.irrigation import get_irrigation_recommendation + + serializer = IrrigationRecommendRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + farm_uuid = validated["farm_uuid"] + plant_name = validated.get("plant_name") + growth_stage = validated.get("growth_stage") + irrigation_method_name = validated.get("irrigation_method_name") + try: + result = get_irrigation_recommendation( + farm_uuid=farm_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + irrigation_method_name=irrigation_method_name, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تولید توصیه آبیاری: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + final_result = _prepare_irrigation_recommendation_response(result) or {} + return Response( + {"code": 200, "msg": "success", "data": final_result}, + status=status.HTTP_200_OK, + ) + + +class IrrigationPlanParserView(APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + summary="استخراج برنامه آبیاری از متن آزاد", + description=( + "توضیح متنی کاربر درباره برنامه آبیاری را می گیرد و آن را به JSON ساختاریافته تبدیل می کند. " + "اگر اطلاعات کافی نباشد، سوالات تکمیلی لازم را برمی گرداند تا در درخواست بعدی پاسخ داده شوند." + ), + request=IrrigationPlanParserRequestSerializer, + responses={ + 200: build_response( + IrrigationPlanParserEnvelopeSerializer, + "نتیجه استخراج یا سوالات تکمیلی برنامه آبیاری.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 500: build_response( + IrrigationValidationErrorSerializer, + "خطا در پردازش برنامه آبیاری.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست کامل", + value={ + "message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + }, + request_only=True, + ), + OpenApiExample( + "نمونه درخواست تکمیلی", + value={ + "partial_plan": { + "crop_name": "گوجه فرنگی", + "irrigation_method": "قطره ای", + }, + "answers": { + "water_amount_per_event": "18 لیتر برای هر بوته", + "preferred_time_of_day": "صبح زود", + }, + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = IrrigationPlanParserRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + validated = serializer.validated_data + service = apps.get_app_config("irrigation").get_free_text_plan_parser_service() + try: + result = service.parse_plan( + message=validated.get("message", ""), + answers=validated.get("answers"), + partial_plan=validated.get("partial_plan"), + farm_uuid=validated.get("farm_uuid"), + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پردازش برنامه آبیاری: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "موفق", "data": result}, + status=status.HTTP_200_OK, + ) + + +class IrrigationMethodDetailView(APIView): + """دریافت، ویرایش و حذف یک روش آبیاری.""" + + def _get_method(self, pk): + return IrrigationMethod.objects.filter(pk=pk).first() + + @extend_schema( + tags=["Irrigation"], + summary="جزئیات روش آبیاری", + description="مشخصات یک روش آبیاری را بر اساس شناسه برمی‌گرداند.", + responses={ + 200: build_response( + IrrigationMethodDetailResponseSerializer, + "جزئیات روش آبیاری.", + ), + 404: build_response( + IrrigationValidationErrorSerializer, + "روش آبیاری یافت نشد.", + ), + }, + ) + def get(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + +class WaterStressView(APIView): + @extend_schema( + tags=["Irrigation"], + summary="شاخص تنش آبی مزرعه", + description=( + "با دریافت farm_uuid، شاخص تنش آبی مزرعه را با استفاده از شبیه سازی " + "crop_simulation و داده هایی مثل رطوبت خاک، ET0، بارش، مرحله رشد و پارامترهای خاک برمی گرداند." + ), + request=WaterStressRequestSerializer, + responses={ + 200: build_response(WaterStressEnvelopeSerializer, "شاخص تنش آبی مزرعه."), + 400: build_response(IrrigationValidationErrorSerializer, "پارامتر ورودی نامعتبر است."), + 404: build_response(IrrigationValidationErrorSerializer, "مزرعه یافت نشد."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست water stress", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_recommendation": {"events": [{"date": "2026-04-25", "amount": 2.5}]}, + "fertilization_recommendation": { + "events": [{"date": "2026-04-20", "N_amount": 45, "N_recovery": 0.7}] + }, + }, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = WaterStressRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + service = apps.get_app_config("irrigation").get_water_stress_service() + try: + data = service.get_water_stress( + farm_uuid=serializer.validated_data["farm_uuid"], + plant_name=serializer.validated_data.get("plant_name"), + irrigation_recommendation=serializer.validated_data.get("irrigation_recommendation"), + fertilization_recommendation=serializer.validated_data.get("fertilization_recommendation"), + ) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Irrigation"], + summary="ویرایش کامل روش آبیاری", + description="تمام فیلدهای یک روش آبیاری را آپدیت می‌کند.", + request=IrrigationMethodSerializer, + responses={ + 200: build_response( + IrrigationMethodDetailResponseSerializer, + "روش آبیاری با موفقیت به‌روزرسانی شد.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + IrrigationValidationErrorSerializer, + "روش آبیاری یافت نشد.", + ), + }, + ) + def put(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method, data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="ویرایش جزئی روش آبیاری", + description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", + request=IrrigationMethodSerializer, + responses={ + 200: build_response( + IrrigationMethodDetailResponseSerializer, + "روش آبیاری با موفقیت به‌روزرسانی شد.", + ), + 400: build_response( + IrrigationValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + IrrigationValidationErrorSerializer, + "روش آبیاری یافت نشد.", + ), + }, + ) + def patch(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method, data=request.data, partial=True) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="حذف روش آبیاری", + description="یک روش آبیاری را حذف می‌کند.", + responses={ + 200: build_response( + IrrigationValidationErrorSerializer, + "روش آبیاری با موفقیت حذف شد.", + ), + 404: build_response( + IrrigationValidationErrorSerializer, + "روش آبیاری یافت نشد.", + ), + }, + ) + def delete(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + method.delete() + return Response( + {"code": 200, "msg": "روش آبیاری با موفقیت حذف شد.", "data": None}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/location_data/LOCATION_DATA_FLOW.md b/Modules/Ai/location_data/LOCATION_DATA_FLOW.md new file mode 100644 index 0000000..89cf262 --- /dev/null +++ b/Modules/Ai/location_data/LOCATION_DATA_FLOW.md @@ -0,0 +1,378 @@ +# جریان واقعی `location_data` + +این توضیح دقیقاً بر اساس منطق جدید نوشته شده: + +- اول مختصات گوشه‌های کل زمین گرفته می‌شود +- بعد مختصات بلوک‌هایی که کشاورز خودش تعریف کرده گرفته می‌شود +- هر بلوک جداگانه به grid های `30×30` تبدیل می‌شود +- برای هر grid داده‌ی یک بازه زمانی از openEO گرفته می‌شود +- میانگین همان بازه، وضعیت نهایی همان grid حساب می‌شود +- بعد برای همان grid ها `KMeans` اجرا می‌شود +- برای هر `K` مقدار `SSE / Inertia` ذخیره می‌شود +- نمودار `K - SSE` رسم می‌شود +- نقطه‌ای که افت شیب ناگهانی دارد به عنوان تعداد مناسب زیر‌بلوک‌ها انتخاب می‌شود +- در نهایت هر بلوک کشاورز به چند زیر‌بلوک داده‌محور تقسیم می‌شود + +--- + +## 1) ورودی مرحله اول + +در مرحله اول این داده‌ها ثبت می‌شوند: + +- مختصات گوشه‌های کل زمین +- مختصات بلوک‌هایی که کشاورز تعریف کرده +- کد هر بلوک + +فایل اصلی: + +- `location_data/views.py` +- `location_data/serializers.py` +- `location_data/models.py` + +خروجی این مرحله: + +- یک `SoilLocation` برای زمین +- یک `block_layout` که داخلش boundary هر بلوک هست +- یک `BlockSubdivision` برای هر بلوک، فقط به عنوان تعریف مرز بلوک کشاورز + +نکته مهم: + +- در این مرحله هیچ subdivision سنکرونی اجرا نمی‌شود +- هیچ داده خاکی از adapter قدیمی گرفته نمی‌شود + +--- + +## 2) هر بلوک کشاورز جداگانه grid می‌شود + +فایل اصلی: + +- `location_data/grid_analysis.py` + +اینجا چه اتفاقی می‌افتد: + +- boundary هر بلوک خوانده می‌شود +- آن بلوک به cell های `30×30` متر تبدیل می‌شود +- برای هر cell یک رکورد ساخته می‌شود + +مدل ذخیره: + +- `AnalysisGridCell` + +هر `AnalysisGridCell` این چیزها را نگه می‌دارد: + +- `cell_code` +- `block_code` +- `geometry` +- `centroid_lat` +- `centroid_lon` +- `chunk_size_sqm` + +یعنی از اینجا به بعد، کوچک‌ترین واحد تحلیل ما دیگر خود بلوک نیست؛ +بلکه grid های `30×30` داخل هر بلوک هستند. + +--- + +## 3) داده ماهواره‌ای هر grid از openEO گرفته می‌شود + +فایل اصلی: + +- `location_data/openeo_service.py` + +منطق این بخش شبیه همان چیزی است که گفتی: + +- برای هر بازه زمانی، cube هر سنجنده load می‌شود +- روی زمان `mean_time()` زده می‌شود +- بعد برای geometry هر grid از `aggregate_spatial(..., reducer=\"mean\")` استفاده می‌شود + +یعنی: + +- داده خام چند روز یا یک ماهه می‌آید +- میانگین همان بازه زمانی برای هر grid محاسبه می‌شود +- همان مقدار میانگین، وضعیت نهایی آن grid در آن بازه است + +metric هایی که الان گرفته می‌شوند: + +- `ndvi` +- `ndwi` +- `lst_c` +- `soil_vv` +- `soil_vv_db` +- `dem_m` +- `slope_deg` + +نکته مهم: + +- این داده‌ها برای **تمام grid های یک بلوک** گرفته می‌شوند +- نه فقط برای مرکز مزرعه +- نه فقط برای geometry خام + +--- + +## 4) داده هر grid داخل جدول ذخیره می‌شود + +فایل اصلی: + +- `location_data/tasks.py` + +مدل ذخیره: + +- `AnalysisGridObservation` + +برای هر grid و هر بازه زمانی، این داده‌ها ذخیره می‌شوند: + +- `ndvi` +- `ndwi` +- `lst_c` +- `soil_vv` +- `soil_vv_db` +- `dem_m` +- `slope_deg` + +پس هر grid یک بردار ویژگی واقعی دارد. + +یعنی به زبان ساده: + +- هر خانه 30×30 فقط یک polygon نیست +- یک وضعیت داده‌ای واقعی هم دارد + +--- + +## 5) اینجا یادگیری بدون نظارت استفاده می‌شود + +فایل اصلی: + +- `location_data/data_driven_subdivision.py` + +اینجا از: + +- `KMeans` + +استفاده می‌شود. + +این بخش unsupervised است چون: + +- هیچ label آماده‌ای نداریم +- فقط می‌خواهیم grid هایی که از نظر رفتار ماهواره‌ای شبیه هم هستند در یک گروه قرار بگیرند + +--- + +## 6) feature matrix دقیقاً از چه چیزی ساخته می‌شود؟ + +هر سطر: + +- یک `AnalysisGridCell` + +هر ستون: + +- یکی از feature های ماهواره‌ای + +feature های پیش‌فرض: + +- `ndvi` +- `ndwi` +- `lst_c` +- `soil_vv_db` +- `dem_m` +- `slope_deg` + +یعنی ورودی `KMeans` از observation های واقعی می‌آید، نه از مختصات هندسی. + +--- + +## 7) داده ناقص چطور مدیریت می‌شود؟ + +قبل از اجرای KMeans: + +- اگر یک grid برای همه feature ها خالی باشد، حذف می‌شود +- اگر فقط بعضی feature ها خالی باشند، مقداردهی می‌شود + +روش فعلی: + +- `median imputation` + +بعد از آن: + +- داده‌ها استاندارد می‌شوند + +روش فعلی: + +- `StandardScaler` + +این کار لازم است چون: + +- مقیاس `ndvi` با `dem_m` فرق دارد +- مقیاس `dem_m` با `lst_c` فرق دارد + +--- + +## 8) برای هر K مقدار SSE ذخیره می‌شود + +فایل اصلی: + +- `location_data/data_driven_subdivision.py` +- `location_data/block_subdivision.py` + +در زمان انتخاب تعداد خوشه: + +- برای `K = 1, 2, 3, ...` +- مدل اجرا می‌شود +- مقدار `SSE / Inertia` ذخیره می‌شود + +این داده داخل metadata نتیجه clustering ذخیره می‌شود. + +پس ما برای هر بلوک این را داریم: + +- لیست `K` +- مقدار `SSE` هر `K` + +--- + +## 9) نمودار `K - SSE` رسم می‌شود + +منطق رسم نمودار در سیستم وجود دارد و از همان منطق elbow استفاده می‌شود. + +هدف نمودار: + +- ببینیم از چه جایی به بعد کم شدن SSE دیگر خیلی شدید نیست +- یعنی شیب نمودار ناگهان کمتر می‌شود + +همان نقطه: + +- تعداد مناسب زیر‌بلوک‌های آن بلوک است + +به زبان ساده: + +- اگر شیب تا `K=3` خیلی زیاد کم شود +- ولی بعد از آن خیلی آرام شود +- `K=3` انتخاب مناسب است + +--- + +## 10) هر بلوک کشاورز جداگانه خوشه‌بندی می‌شود + +این خیلی مهم است: + +- کل مزرعه یکجا خوشه‌بندی نمی‌شود +- هر بلوکی که کشاورز تعریف کرده جداگانه پردازش می‌شود + +پس برای هر بلوک: + +1. grid های 30×30 ساخته می‌شوند +2. داده ماهواره‌ای همان grid ها گرفته می‌شود +3. observation ذخیره می‌شود +4. `KMeans` فقط روی grid های همان بلوک اجرا می‌شود +5. تعداد زیر‌بلوک‌های مناسب همان بلوک تعیین می‌شود + +--- + +## 11) نتیجه subdivision جدید کجا ذخیره می‌شود؟ + +مدل اصلی نتیجه: + +- `RemoteSensingSubdivisionResult` + +این مدل چیزهای اصلی را نگه می‌دارد: + +- `block_code` +- `cluster_count` +- `selected_features` +- `skipped_cell_codes` +- `kmeans_params` +- `inertia_curve` +- `cluster_summaries` + +و برای هر grid هم assignment جدا ذخیره می‌شود در: + +- `RemoteSensingClusterAssignment` + +یعنی برای هر grid مشخص است: + +- در کدام cluster قرار گرفته +- raw feature هایش چه بوده +- scaled feature هایش چه بوده + +--- + +## 12) `BlockSubdivision` الان چه نقشی دارد؟ + +الان `BlockSubdivision` دیگر مدل اصلی خوشه‌بندی نیست. + +نقشش این است که: + +- boundary بلوک کشاورز را نگه دارد +- metadata بلوک را نگه دارد +- به grid سازی و pipeline کمک کند + +اما نتیجه اصلی data-driven subdivision در این دو مدل ذخیره می‌شود: + +- `RemoteSensingSubdivisionResult` +- `RemoteSensingClusterAssignment` + +--- + +## 13) اجرای async کجا انجام می‌شود؟ + +فایل اصلی: + +- `location_data/tasks.py` + +این pipeline داخل Celery اجرا می‌شود. + +مراحل run: + +1. run ساخته می‌شود +2. grid های بلوک ساخته می‌شوند +3. داده openEO گرفته می‌شود +4. observation ها ذخیره می‌شوند +5. feature matrix ساخته می‌شود +6. `KMeans` اجرا می‌شود +7. نتیجه نهایی ذخیره می‌شود + +مدل status: + +- `RemoteSensingRun` + +وضعیت‌هایی که track می‌شوند: + +- `pending` +- `running` +- `failed` +- `completed` + +--- + +## 14) چیزی که حذف شده + +این بخش‌ها دیگر منبع اصلی داده نیستند و باید حذف‌شده در نظر گرفته شوند: + +- منطق قدیمی دریافت soil depth +- adapter های خاک +- وابستگی اصلی به `SoilDepthData` + +منبع اصلی داده از این به بعد: + +- داده ماهواره‌ای هر grid + +یعنی: + +- به جای جدول depth-based +- جدول observation های ماهواره‌ای grid-based مرجع اصلی است + +--- + +## 15) خلاصه خیلی کوتاه + +جریان نهایی این است: + +1. گوشه‌های زمین و بلوک‌های کشاورز ثبت می‌شوند +2. هر بلوک به grid های `30×30` تبدیل می‌شود +3. برای هر grid داده‌ی ماهواره‌ای یک بازه زمانی از openEO گرفته می‌شود +4. میانگین آن بازه، وضعیت همان grid می‌شود +5. همه grid ها در جدول observation ذخیره می‌شوند +6. برای هر بلوک، روی feature های grid ها `KMeans` اجرا می‌شود +7. برای هر `K` مقدار `SSE` ذخیره می‌شود +8. نمودار `K - SSE` ساخته می‌شود +9. elbow point تعداد مناسب زیر‌بلوک‌ها را مشخص می‌کند +10. هر بلوک کشاورز به چند زیر‌بلوک داده‌محور تقسیم می‌شود + +این دقیقاً همان منطق اصلی جدید سیستم است. diff --git a/Modules/Ai/location_data/__init__.py b/Modules/Ai/location_data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/location_data/admin.py b/Modules/Ai/location_data/admin.py new file mode 100644 index 0000000..a45dd89 --- /dev/null +++ b/Modules/Ai/location_data/admin.py @@ -0,0 +1,136 @@ +from django.contrib import admin +from .models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingClusterAssignment, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) + + +class BlockSubdivisionInline(admin.TabularInline): + model = BlockSubdivision + extra = 0 + readonly_fields = ( + "block_code", + "chunk_size_sqm", + "grid_point_count", + "centroid_count", + "status", + "created_at", + "updated_at", + ) + fields = readonly_fields + show_change_link = True + + +@admin.register(SoilLocation) +class SoilLocationAdmin(admin.ModelAdmin): + list_display = ("id", "latitude", "longitude", "is_complete", "created_at") + list_filter = ("created_at",) + search_fields = ("latitude", "longitude") + readonly_fields = ("created_at", "updated_at") + inlines = [BlockSubdivisionInline] + + +@admin.register(BlockSubdivision) +class BlockSubdivisionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "soil_location", + "block_code", + "chunk_size_sqm", + "grid_point_count", + "centroid_count", + "status", + "updated_at", + ) + list_filter = ("status", "chunk_size_sqm", "created_at") + search_fields = ("block_code", "soil_location__latitude", "soil_location__longitude") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(RemoteSensingRun) +class RemoteSensingRunAdmin(admin.ModelAdmin): + list_display = ( + "id", + "soil_location", + "block_code", + "provider", + "chunk_size_sqm", + "status", + "temporal_start", + "temporal_end", + "created_at", + ) + list_filter = ("provider", "status", "chunk_size_sqm", "created_at") + search_fields = ("block_code", "soil_location__latitude", "soil_location__longitude") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(AnalysisGridCell) +class AnalysisGridCellAdmin(admin.ModelAdmin): + list_display = ( + "id", + "cell_code", + "soil_location", + "block_code", + "chunk_size_sqm", + "centroid_lat", + "centroid_lon", + "created_at", + ) + list_filter = ("chunk_size_sqm", "created_at") + search_fields = ("cell_code", "block_code", "soil_location__latitude", "soil_location__longitude") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(AnalysisGridObservation) +class AnalysisGridObservationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "cell", + "temporal_start", + "temporal_end", + "ndvi", + "ndwi", + "lst_c", + "created_at", + ) + list_filter = ("temporal_start", "temporal_end", "created_at") + search_fields = ("cell__cell_code", "cell__block_code") + readonly_fields = ("created_at", "updated_at") + + + +@admin.register(RemoteSensingSubdivisionResult) +class RemoteSensingSubdivisionResultAdmin(admin.ModelAdmin): + list_display = ( + "id", + "soil_location", + "block_code", + "cluster_count", + "chunk_size_sqm", + "temporal_start", + "temporal_end", + "created_at", + ) + list_filter = ("chunk_size_sqm", "cluster_count", "created_at") + search_fields = ("block_code", "soil_location__latitude", "soil_location__longitude") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(RemoteSensingClusterAssignment) +class RemoteSensingClusterAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "id", + "result", + "cell", + "cluster_label", + "created_at", + ) + list_filter = ("cluster_label", "created_at") + search_fields = ("cell__cell_code", "result__block_code") + readonly_fields = ("created_at", "updated_at") diff --git a/Modules/Ai/location_data/apps.py b/Modules/Ai/location_data/apps.py new file mode 100644 index 0000000..cc36401 --- /dev/null +++ b/Modules/Ai/location_data/apps.py @@ -0,0 +1,18 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class SoilDataConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "location_data" + verbose_name = "Location Data (Remote Sensing)" + + @cached_property + def ndvi_health_service(self): + from .ndvi import NdviHealthService + + return NdviHealthService() + + def get_ndvi_health_service(self): + return self.ndvi_health_service diff --git a/Modules/Ai/location_data/block_subdivision.py b/Modules/Ai/location_data/block_subdivision.py new file mode 100644 index 0000000..c4b57ba --- /dev/null +++ b/Modules/Ai/location_data/block_subdivision.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from io import BytesIO +import math + +from django.conf import settings +from django.core.files.base import ContentFile + + +EARTH_RADIUS_M = 6371008.8 +COORD_PRECISION = Decimal("0.000001") +MAX_K = 10 +RANDOM_STATE = 42 + + +@dataclass(frozen=True) +class GeoPoint: + lat: float + lon: float + + +def create_or_get_block_subdivision( + location, + block_code: str, + boundary: dict | list, + *, + chunk_size_sqm: int | None = None, +): + """ + اگر subdivision این بلوک قبلاً ساخته شده باشد همان را برمی‌گرداند؛ + در غیر این صورت الگوریتم grid + KMeans را اجرا و ذخیره می‌کند. + """ + from .models import BlockSubdivision + + existing = BlockSubdivision.objects.filter( + soil_location=location, + block_code=block_code, + ).first() + if existing is not None: + return existing, False + + payload = build_block_subdivision_payload( + boundary=boundary, + block_code=block_code, + chunk_size_sqm=chunk_size_sqm, + ) + subdivision = BlockSubdivision.objects.create( + soil_location=location, + block_code=block_code, + source_boundary=payload["source_boundary"], + chunk_size_sqm=payload["chunk_size_sqm"], + grid_points=payload["grid_points"], + centroid_points=payload["centroid_points"], + grid_point_count=payload["grid_point_count"], + centroid_count=payload["centroid_count"], + status="created", + metadata=payload["metadata"], + ) + plot_content = render_elbow_plot( + inertia_curve=payload["metadata"].get("inertia_curve", []), + optimal_k=payload["metadata"].get("optimal_k", 0), + block_code=block_code, + ) + if plot_content is not None: + subdivision.elbow_plot.save( + f"{location.pk}_{block_code}_elbow.png", + plot_content, + save=False, + ) + subdivision.save(update_fields=["elbow_plot", "updated_at"]) + sync_block_layout_with_subdivision(location, subdivision) + return subdivision, True + + +def build_block_subdivision_payload( + boundary: dict | list, + block_code: str = "block-1", + chunk_size_sqm: int | None = None, +) -> dict: + """ + مرز یک بلوک را گرفته و ابتدا شبکه نقاط را می‌سازد، سپس با KMeans + تعداد بهینه خوشه‌ها را از elbow point پیدا می‌کند و centroidها را برمی‌گرداند. + """ + chunk_size = int(chunk_size_sqm or getattr(settings, "SUBDIVISION_CHUNK_SQM", 100) or 100) + if chunk_size <= 0: + raise ValueError("chunk_size_sqm باید بزرگ‌تر از صفر باشد.") + + polygon = extract_polygon(boundary) + if len(polygon) < 3: + raise ValueError("مرز بلوک باید حداقل سه نقطه معتبر داشته باشد.") + + projected_polygon = project_polygon_to_local_meters(polygon) + area_sqm = abs(polygon_area(projected_polygon)) + grid_points, grid_vectors = generate_grid_points( + polygon=polygon, + projected_polygon=projected_polygon, + chunk_size_sqm=chunk_size, + ) + clustering_result = cluster_grid_points(grid_vectors, polygon) + + return { + "block_code": block_code, + "source_boundary": boundary if isinstance(boundary, dict) else {"points": boundary}, + "chunk_size_sqm": chunk_size, + "grid_points": grid_points, + "centroid_points": clustering_result["centroid_points"], + "grid_point_count": len(grid_points), + "centroid_count": len(clustering_result["centroid_points"]), + "metadata": { + "estimated_area_sqm": round(area_sqm, 2), + "optimal_k": clustering_result["optimal_k"], + "inertia_curve": clustering_result["inertia_curve"], + }, + } + + +def cluster_grid_points(grid_vectors: list[tuple[float, float]], polygon: list[GeoPoint]) -> dict: + if not grid_vectors: + return { + "optimal_k": 0, + "inertia_curve": [], + "centroid_points": [], + } + + if len(grid_vectors) == 1: + lat, lon = unproject_point(grid_vectors[0][0], grid_vectors[0][1], polygon) + return { + "optimal_k": 1, + "inertia_curve": [{"k": 1, "sse": 0.0}], + "centroid_points": [ + { + "sub_block_code": "sub-block-1", + "centroid_lat": quantize_coordinate(lat), + "centroid_lon": quantize_coordinate(lon), + } + ], + } + + try: + from sklearn.cluster import KMeans + except ImportError as exc: # pragma: no cover - runtime dependency guard + raise ImportError("scikit-learn برای اجرای subdivision لازم است.") from exc + + max_k = min(MAX_K, len(grid_vectors)) + inertia_curve = [] + trained_models = {} + for k in range(1, max_k + 1): + model = KMeans( + n_clusters=k, + n_init=10, + random_state=RANDOM_STATE, + ) + model.fit(grid_vectors) + trained_models[k] = model + inertia_curve.append({"k": k, "sse": round(float(model.inertia_), 6)}) + + optimal_k = detect_elbow_point(inertia_curve) + final_model = trained_models[optimal_k] + centroid_points = [] + for index, center in enumerate(final_model.cluster_centers_, start=1): + lat, lon = unproject_point(center[0], center[1], polygon) + centroid_points.append( + { + "sub_block_code": f"sub-block-{index}", + "centroid_lat": quantize_coordinate(lat), + "centroid_lon": quantize_coordinate(lon), + } + ) + + return { + "optimal_k": optimal_k, + "inertia_curve": inertia_curve, + "centroid_points": centroid_points, + } + + +def detect_elbow_point(inertia_curve: list[dict]) -> int: + if not inertia_curve: + return 0 + if len(inertia_curve) <= 2: + return inertia_curve[-1]["k"] if len(inertia_curve) == 2 else inertia_curve[0]["k"] + + sses = [item["sse"] for item in inertia_curve] + ks = [item["k"] for item in inertia_curve] + slopes = [sses[index] - sses[index + 1] for index in range(len(sses) - 1)] + + best_k = ks[0] + best_change = float("-inf") + for index in range(len(slopes) - 1): + change = slopes[index] - slopes[index + 1] + candidate_k = ks[index + 1] + if change > best_change: + best_change = change + best_k = candidate_k + return best_k + + +def render_elbow_plot( + inertia_curve: list[dict], + optimal_k: int, + block_code: str, +) -> ContentFile | None: + if not inertia_curve: + return None + + try: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + except ImportError as exc: # pragma: no cover - runtime dependency guard + raise ImportError("matplotlib برای ذخیره نمودار elbow لازم است.") from exc + + ks = [item["k"] for item in inertia_curve] + sses = [item["sse"] for item in inertia_curve] + buffer = BytesIO() + fig, ax = plt.subplots(figsize=(8, 5)) + try: + ax.plot(ks, sses, marker="o", linewidth=2, color="#2f6fed") + if optimal_k in ks: + elbow_index = ks.index(optimal_k) + ax.scatter( + [ks[elbow_index]], + [sses[elbow_index]], + color="#d62828", + s=90, + zorder=3, + label=f"Elbow K={optimal_k}", + ) + ax.legend() + ax.set_title(f"Elbow Plot - {block_code}") + ax.set_xlabel("K") + ax.set_ylabel("SSE / Inertia") + ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.6) + fig.tight_layout() + fig.savefig(buffer, format="png", dpi=150) + buffer.seek(0) + return ContentFile(buffer.getvalue()) + finally: + buffer.close() + plt.close(fig) + + +def sync_block_layout_with_subdivision(location, subdivision) -> None: + layout = location.block_layout or {} + blocks = list(layout.get("blocks") or []) + target_block = None + for block in blocks: + if block.get("block_code") == subdivision.block_code: + target_block = block + break + + if target_block is None: + target_block = { + "block_code": subdivision.block_code, + "order": len(blocks) + 1, + "source": "input", + "needs_subdivision": None, + "sub_blocks": [], + } + blocks.append(target_block) + + target_block["needs_subdivision"] = subdivision.centroid_count > 1 + target_block["sub_blocks"] = list(subdivision.centroid_points or []) + target_block["subdivision_summary"] = { + "chunk_size_sqm": subdivision.chunk_size_sqm, + "grid_point_count": subdivision.grid_point_count, + "centroid_count": subdivision.centroid_count, + "optimal_k": (subdivision.metadata or {}).get("optimal_k", subdivision.centroid_count), + } + layout["blocks"] = blocks + layout["algorithm_status"] = "completed" + location.block_layout = layout + location.save(update_fields=["block_layout", "updated_at"]) + + +def generate_grid_points( + polygon: list[GeoPoint], + projected_polygon: list[tuple[float, float]], + chunk_size_sqm: int, +) -> tuple[list[dict], list[tuple[float, float]]]: + step_m = math.sqrt(chunk_size_sqm) + min_x, max_x, min_y, max_y = bounds(projected_polygon) + grid_points: list[dict] = [] + grid_vectors: list[tuple[float, float]] = [] + + y = min_y + (step_m / 2.0) + point_index = 0 + while y <= max_y: + x = min_x + (step_m / 2.0) + while x <= max_x: + if point_in_polygon((x, y), projected_polygon): + lat, lon = unproject_point(x, y, polygon) + point_index += 1 + grid_vectors.append((x, y)) + grid_points.append( + { + "point_code": f"pt-{point_index}", + "lat": quantize_coordinate(lat), + "lon": quantize_coordinate(lon), + } + ) + x += step_m + y += step_m + return grid_points, grid_vectors + + +def extract_polygon(boundary: dict | list) -> list[GeoPoint]: + if isinstance(boundary, dict): + if boundary.get("type") == "Polygon": + coordinates = boundary.get("coordinates") or [] + if coordinates and isinstance(coordinates[0], list): + points = coordinates[0] + else: + points = [] + else: + points = boundary.get("corners") or [] + elif isinstance(boundary, list): + points = boundary + else: + points = [] + + polygon: list[GeoPoint] = [] + for point in points: + lat = lon = None + if isinstance(point, dict): + lat = point.get("lat", point.get("latitude")) + lon = point.get("lon", point.get("longitude")) + elif isinstance(point, (list, tuple)) and len(point) >= 2: + lon, lat = point[0], point[1] + + if lat is None or lon is None: + continue + polygon.append(GeoPoint(lat=float(lat), lon=float(lon))) + + if len(polygon) > 1 and polygon[0] == polygon[-1]: + polygon = polygon[:-1] + return polygon + + +def project_polygon_to_local_meters(polygon: list[GeoPoint]) -> list[tuple[float, float]]: + origin = polygon[0] + lat0 = math.radians(origin.lat) + lon0 = math.radians(origin.lon) + cos_lat0 = math.cos(lat0) + projected = [] + for point in polygon: + lat = math.radians(point.lat) + lon = math.radians(point.lon) + x = (lon - lon0) * cos_lat0 * EARTH_RADIUS_M + y = (lat - lat0) * EARTH_RADIUS_M + projected.append((x, y)) + return projected + + +def unproject_point(x: float, y: float, polygon: list[GeoPoint]) -> tuple[float, float]: + origin = polygon[0] + lat0 = math.radians(origin.lat) + lon0 = math.radians(origin.lon) + cos_lat0 = math.cos(lat0) + lat = math.degrees((y / EARTH_RADIUS_M) + lat0) + lon = math.degrees((x / (EARTH_RADIUS_M * cos_lat0)) + lon0) + return lat, lon + + +def polygon_area(points: list[tuple[float, float]]) -> float: + area = 0.0 + closed = points + [points[0]] + for index in range(len(points)): + x1, y1 = closed[index] + x2, y2 = closed[index + 1] + area += (x1 * y2) - (x2 * y1) + return area / 2.0 + + +def bounds(points: list[tuple[float, float]]) -> tuple[float, float, float, float]: + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return min(xs), max(xs), min(ys), max(ys) + + +def point_in_polygon(point: tuple[float, float], polygon: list[tuple[float, float]]) -> bool: + x, y = point + inside = False + j = len(polygon) - 1 + for i in range(len(polygon)): + xi, yi = polygon[i] + xj, yj = polygon[j] + intersects = ((yi > y) != (yj > y)) and ( + x < ((xj - xi) * (y - yi) / ((yj - yi) or 1e-12)) + xi + ) + if intersects: + inside = not inside + j = i + return inside + + +def quantize_coordinate(value: float) -> float: + return float(Decimal(str(value)).quantize(COORD_PRECISION, rounding=ROUND_HALF_UP)) diff --git a/Modules/Ai/location_data/data_driven_subdivision.py b/Modules/Ai/location_data/data_driven_subdivision.py new file mode 100644 index 0000000..3b41253 --- /dev/null +++ b/Modules/Ai/location_data/data_driven_subdivision.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from django.db import transaction + +from .block_subdivision import detect_elbow_point, render_elbow_plot +from .models import ( + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingClusterAssignment, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) + + +DEFAULT_CLUSTER_FEATURES = [ + "ndvi", + "ndwi", + "lst_c", + "soil_vv_db", + "dem_m", + "slope_deg", +] +SUPPORTED_CLUSTER_FEATURES = tuple(DEFAULT_CLUSTER_FEATURES) +DEFAULT_RANDOM_STATE = 42 +DEFAULT_MAX_K = 10 + + +class DataDrivenSubdivisionError(Exception): + """Raised when remote-sensing-driven subdivision can not be computed.""" + + +@dataclass +class ClusteringDataset: + observations: list[AnalysisGridObservation] + selected_features: list[str] + raw_feature_rows: list[list[float | None]] + raw_feature_maps: list[dict[str, float | None]] + skipped_cell_codes: list[str] + used_cell_codes: list[str] + imputed_matrix: list[list[float]] + scaled_matrix: list[list[float]] + imputer_statistics: dict[str, float | None] + scaler_means: dict[str, float] + scaler_scales: dict[str, float] + missing_value_counts: dict[str, int] + skipped_reasons: dict[str, list[str]] + + +def create_remote_sensing_subdivision_result( + *, + location: SoilLocation, + run: RemoteSensingRun, + observations: list[AnalysisGridObservation], + block_subdivision: BlockSubdivision | None = None, + block_code: str = "", + selected_features: list[str] | None = None, + explicit_k: int | None = None, + max_k: int = DEFAULT_MAX_K, + random_state: int = DEFAULT_RANDOM_STATE, +) -> RemoteSensingSubdivisionResult: + """ + Build a data-driven subdivision result from stored remote sensing observations. + + KMeans is applied on actual per-cell feature vectors, not geometric points. + """ + dataset = build_clustering_dataset( + observations=observations, + selected_features=selected_features, + ) + if not dataset.observations: + raise DataDrivenSubdivisionError("هیچ observation قابل استفاده‌ای برای خوشه‌بندی باقی نماند.") + + optimal_k, inertia_curve = choose_cluster_count( + scaled_matrix=dataset.scaled_matrix, + explicit_k=explicit_k, + max_k=max_k, + random_state=random_state, + ) + cluster_selection_strategy = "explicit_k" if explicit_k is not None else "elbow" + labels = run_kmeans_labels( + scaled_matrix=dataset.scaled_matrix, + cluster_count=optimal_k, + random_state=random_state, + ) + cluster_summaries = build_cluster_summaries( + observations=dataset.observations, + labels=labels, + ) + + with transaction.atomic(): + result, _created = RemoteSensingSubdivisionResult.objects.update_or_create( + run=run, + defaults={ + "soil_location": location, + "block_subdivision": block_subdivision, + "block_code": block_code, + "chunk_size_sqm": run.chunk_size_sqm, + "temporal_start": run.temporal_start, + "temporal_end": run.temporal_end, + "cluster_count": optimal_k, + "selected_features": dataset.selected_features, + "skipped_cell_codes": dataset.skipped_cell_codes, + "metadata": { + "cell_count": len(observations), + "used_cell_count": len(dataset.observations), + "skipped_cell_count": len(dataset.skipped_cell_codes), + "used_cell_codes": dataset.used_cell_codes, + "skipped_reasons": dataset.skipped_reasons, + "selected_features": dataset.selected_features, + "imputer_strategy": "median", + "imputer_statistics": dataset.imputer_statistics, + "missing_value_counts": dataset.missing_value_counts, + "scaler_means": dataset.scaler_means, + "scaler_scales": dataset.scaler_scales, + "kmeans_params": { + "random_state": random_state, + "explicit_k": explicit_k, + "selected_k": optimal_k, + "max_k": max_k, + "n_init": 10, + "selection_strategy": cluster_selection_strategy, + }, + "inertia_curve": inertia_curve, + "cluster_summaries": cluster_summaries, + }, + }, + ) + result.assignments.all().delete() + assignment_rows = [] + for index, observation in enumerate(dataset.observations): + assignment_rows.append( + RemoteSensingClusterAssignment( + result=result, + cell=observation.cell, + cluster_label=int(labels[index]), + raw_feature_values=dataset.raw_feature_maps[index], + scaled_feature_values={ + feature_name: round(dataset.scaled_matrix[index][feature_index], 6) + for feature_index, feature_name in enumerate(dataset.selected_features) + }, + ) + ) + RemoteSensingClusterAssignment.objects.bulk_create(assignment_rows) + sync_location_block_layout_with_result( + location=location, + result=result, + cluster_summaries=cluster_summaries, + ) + if block_subdivision is not None: + metadata = dict(block_subdivision.metadata or {}) + metadata["data_driven_subdivision"] = { + "run_id": run.id, + "cluster_count": optimal_k, + "used_cell_count": len(dataset.observations), + "skipped_cell_count": len(dataset.skipped_cell_codes), + } + block_subdivision.metadata = metadata + plot_content = render_elbow_plot( + inertia_curve=inertia_curve, + optimal_k=optimal_k, + block_code=block_code or block_subdivision.block_code, + ) + if plot_content is not None: + block_subdivision.elbow_plot.save( + f"remote-sensing-{location.pk}-{block_code or block_subdivision.block_code}-elbow.png", + plot_content, + save=False, + ) + block_subdivision.save(update_fields=["metadata", "elbow_plot", "updated_at"]) + else: + block_subdivision.save(update_fields=["metadata", "updated_at"]) + return result + + +def build_clustering_dataset( + *, + observations: list[AnalysisGridObservation], + selected_features: list[str] | None = None, +) -> ClusteringDataset: + selected_features = list(selected_features or DEFAULT_CLUSTER_FEATURES) + invalid_features = [ + feature_name + for feature_name in selected_features + if feature_name not in SUPPORTED_CLUSTER_FEATURES + ] + if invalid_features: + raise DataDrivenSubdivisionError( + "ویژگی‌های نامعتبر برای خوشه‌بندی: " + + ", ".join(sorted(invalid_features)) + ) + raw_rows: list[list[float | None]] = [] + raw_maps: list[dict[str, float | None]] = [] + usable_observations: list[AnalysisGridObservation] = [] + skipped_cell_codes: list[str] = [] + used_cell_codes: list[str] = [] + missing_value_counts = {feature_name: 0 for feature_name in selected_features} + skipped_reasons = {"all_features_missing": []} + + for observation in observations: + feature_map = { + feature_name: _coerce_float(getattr(observation, feature_name, None)) + for feature_name in selected_features + } + for feature_name, value in feature_map.items(): + if value is None: + missing_value_counts[feature_name] += 1 + if all(value is None for value in feature_map.values()): + skipped_cell_codes.append(observation.cell.cell_code) + skipped_reasons["all_features_missing"].append(observation.cell.cell_code) + continue + usable_observations.append(observation) + used_cell_codes.append(observation.cell.cell_code) + raw_maps.append(feature_map) + raw_rows.append([feature_map[feature_name] for feature_name in selected_features]) + + if not usable_observations: + return ClusteringDataset( + observations=[], + selected_features=selected_features, + raw_feature_rows=[], + raw_feature_maps=[], + skipped_cell_codes=skipped_cell_codes, + used_cell_codes=[], + imputed_matrix=[], + scaled_matrix=[], + imputer_statistics={feature_name: None for feature_name in selected_features}, + scaler_means={feature_name: 0.0 for feature_name in selected_features}, + scaler_scales={feature_name: 1.0 for feature_name in selected_features}, + missing_value_counts=missing_value_counts, + skipped_reasons=skipped_reasons, + ) + + try: + import numpy as np + from sklearn.impute import SimpleImputer + from sklearn.preprocessing import StandardScaler + except ImportError as exc: # pragma: no cover - runtime dependency guard + raise DataDrivenSubdivisionError( + "scikit-learn و numpy برای خوشه‌بندی داده‌محور لازم هستند." + ) from exc + + raw_matrix = np.array(raw_rows, dtype=float) + imputer = SimpleImputer(strategy="median") + imputed_matrix = imputer.fit_transform(raw_matrix) + scaler = StandardScaler() + scaled_matrix = scaler.fit_transform(imputed_matrix) + + return ClusteringDataset( + observations=usable_observations, + selected_features=selected_features, + raw_feature_rows=raw_rows, + raw_feature_maps=raw_maps, + skipped_cell_codes=skipped_cell_codes, + used_cell_codes=used_cell_codes, + imputed_matrix=imputed_matrix.tolist(), + scaled_matrix=scaled_matrix.tolist(), + imputer_statistics={ + feature_name: _coerce_float(imputer.statistics_[index]) + for index, feature_name in enumerate(selected_features) + }, + scaler_means={ + feature_name: float(scaler.mean_[index]) + for index, feature_name in enumerate(selected_features) + }, + scaler_scales={ + feature_name: float(scaler.scale_[index] or 1.0) + for index, feature_name in enumerate(selected_features) + }, + missing_value_counts=missing_value_counts, + skipped_reasons=skipped_reasons, + ) + + +def choose_cluster_count( + *, + scaled_matrix: list[list[float]], + explicit_k: int | None, + max_k: int, + random_state: int, +) -> tuple[int, list[dict[str, float]]]: + sample_count = len(scaled_matrix) + if sample_count == 0: + raise DataDrivenSubdivisionError("هیچ نمونه‌ای برای خوشه‌بندی وجود ندارد.") + if sample_count == 1: + return 1, [{"k": 1, "sse": 0.0}] + + if explicit_k is not None: + if explicit_k <= 0: + raise DataDrivenSubdivisionError("cluster_count باید بزرگ‌تر از صفر باشد.") + return min(explicit_k, sample_count), [] + + try: + from sklearn.cluster import KMeans + except ImportError as exc: # pragma: no cover + raise DataDrivenSubdivisionError("scikit-learn برای انتخاب تعداد خوشه لازم است.") from exc + + max_allowed_k = min(max_k, sample_count) + inertia_curve = [] + for k in range(1, max_allowed_k + 1): + model = KMeans(n_clusters=k, n_init=10, random_state=random_state) + model.fit(scaled_matrix) + inertia_curve.append({"k": k, "sse": round(float(model.inertia_), 6)}) + return detect_elbow_point(inertia_curve), inertia_curve + + +def run_kmeans_labels( + *, + scaled_matrix: list[list[float]], + cluster_count: int, + random_state: int, +) -> list[int]: + if cluster_count <= 0: + raise DataDrivenSubdivisionError("cluster_count باید بزرگ‌تر از صفر باشد.") + if len(scaled_matrix) == 1: + return [0] + try: + from sklearn.cluster import KMeans + except ImportError as exc: # pragma: no cover + raise DataDrivenSubdivisionError("scikit-learn برای اجرای KMeans لازم است.") from exc + model = KMeans(n_clusters=cluster_count, n_init=10, random_state=random_state) + return [int(label) for label in model.fit_predict(scaled_matrix)] + + +def build_cluster_summaries( + *, + observations: list[AnalysisGridObservation], + labels: list[int], +) -> list[dict[str, Any]]: + clusters: dict[int, dict[str, Any]] = {} + for observation, label in zip(observations, labels): + cluster = clusters.setdefault( + int(label), + { + "cluster_label": int(label), + "cell_codes": [], + "centroid_lat_sum": 0.0, + "centroid_lon_sum": 0.0, + "cell_count": 0, + }, + ) + cluster["cell_codes"].append(observation.cell.cell_code) + cluster["centroid_lat_sum"] += float(observation.cell.centroid_lat) + cluster["centroid_lon_sum"] += float(observation.cell.centroid_lon) + cluster["cell_count"] += 1 + + summaries = [] + for cluster_label in sorted(clusters): + cluster = clusters[cluster_label] + cell_count = cluster["cell_count"] or 1 + summaries.append( + { + "cluster_label": cluster_label, + "cell_count": cluster["cell_count"], + "centroid_lat": round(cluster["centroid_lat_sum"] / cell_count, 6), + "centroid_lon": round(cluster["centroid_lon_sum"] / cell_count, 6), + "cell_codes": cluster["cell_codes"], + } + ) + return summaries + + +def sync_location_block_layout_with_result( + *, + location: SoilLocation, + result: RemoteSensingSubdivisionResult, + cluster_summaries: list[dict[str, Any]], +) -> None: + layout = dict(location.block_layout or {}) + blocks = list(layout.get("blocks") or []) + target_block = None + for block in blocks: + if block.get("block_code") == result.block_code: + target_block = block + break + + if target_block is None: + target_block = { + "block_code": result.block_code, + "order": len(blocks) + 1, + "source": "remote_sensing", + "needs_subdivision": None, + "sub_blocks": [], + } + blocks.append(target_block) + + target_block["needs_subdivision"] = result.cluster_count > 1 + target_block["sub_blocks"] = [ + { + "sub_block_code": f"cluster-{cluster['cluster_label']}", + "cluster_label": cluster["cluster_label"], + "centroid_lat": cluster["centroid_lat"], + "centroid_lon": cluster["centroid_lon"], + "cell_count": cluster["cell_count"], + } + for cluster in cluster_summaries + ] + target_block["subdivision_summary"] = { + "type": "data_driven_remote_sensing", + "cluster_count": result.cluster_count, + "selected_features": result.selected_features, + "used_cell_count": result.metadata.get("used_cell_count", 0), + "skipped_cell_count": result.metadata.get("skipped_cell_count", 0), + "run_id": result.run_id, + } + layout["blocks"] = blocks + layout["algorithm_status"] = "completed" + location.block_layout = layout + location.save(update_fields=["block_layout", "updated_at"]) + + +def _coerce_float(value: Any) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/Modules/Ai/location_data/grid_analysis.py b/Modules/Ai/location_data/grid_analysis.py new file mode 100644 index 0000000..2fcf21d --- /dev/null +++ b/Modules/Ai/location_data/grid_analysis.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +from decimal import Decimal +import math + +from django.conf import settings +from django.db import transaction + +from .block_subdivision import ( + GeoPoint, + bounds, + extract_polygon, + point_in_polygon, + project_polygon_to_local_meters, + quantize_coordinate, + unproject_point, +) +from .models import AnalysisGridCell, BlockSubdivision, SoilLocation + + +def create_or_get_analysis_grid_cells( + location: SoilLocation, + *, + boundary: dict | list | None = None, + block_code: str | None = None, + block_subdivision: BlockSubdivision | None = None, + chunk_size_sqm: int | None = None, +) -> dict: + """ + شبکه تحلیل 30x30 متری یا هر chunk size تنظیم‌شده را برای مزرعه/بلوک می‌سازد + و رکوردهای AnalysisGridCell را به‌صورت idempotent ذخیره می‌کند. + """ + normalized_chunk_size = int( + chunk_size_sqm or getattr(settings, "SUBDIVISION_CHUNK_SQM", 900) or 900 + ) + if normalized_chunk_size <= 0: + raise ValueError("chunk_size_sqm باید بزرگ‌تر از صفر باشد.") + + resolved_block_code = str(block_code or getattr(block_subdivision, "block_code", "") or "").strip() + resolved_boundary = _resolve_boundary( + location=location, + boundary=boundary, + block_subdivision=block_subdivision, + ) + polygon = extract_polygon(resolved_boundary) + if len(polygon) < 3: + raise ValueError("برای ساخت analysis grid باید حداقل سه نقطه معتبر در boundary وجود داشته باشد.") + + existing_qs = AnalysisGridCell.objects.filter( + soil_location=location, + block_code=resolved_block_code, + chunk_size_sqm=normalized_chunk_size, + ).order_by("cell_code") + existing_count = existing_qs.count() + if existing_count: + return { + "created_count": 0, + "existing_count": existing_count, + "total_count": existing_count, + "chunk_size_sqm": normalized_chunk_size, + "block_code": resolved_block_code, + "created": False, + } + + cell_payloads = build_analysis_grid_payload( + polygon=polygon, + location=location, + block_code=resolved_block_code, + chunk_size_sqm=normalized_chunk_size, + ) + + created_cells = [] + with transaction.atomic(): + for payload in cell_payloads: + created_cells.append( + AnalysisGridCell.objects.create( + soil_location=location, + block_subdivision=block_subdivision, + block_code=resolved_block_code, + cell_code=payload["cell_code"], + chunk_size_sqm=normalized_chunk_size, + geometry=payload["geometry"], + centroid_lat=Decimal(str(payload["centroid_lat"])), + centroid_lon=Decimal(str(payload["centroid_lon"])), + ) + ) + _update_grid_summary_metadata( + location=location, + block_code=resolved_block_code, + chunk_size_sqm=normalized_chunk_size, + total_count=len(created_cells), + block_subdivision=block_subdivision, + ) + + return { + "created_count": len(created_cells), + "existing_count": 0, + "total_count": len(created_cells), + "chunk_size_sqm": normalized_chunk_size, + "block_code": resolved_block_code, + "created": True, + } + + +def build_analysis_grid_payload( + *, + polygon: list[GeoPoint], + location: SoilLocation, + block_code: str, + chunk_size_sqm: int, +) -> list[dict]: + projected_polygon = project_polygon_to_local_meters(polygon) + step_m = math.sqrt(chunk_size_sqm) + min_x, max_x, min_y, max_y = bounds(projected_polygon) + + payloads: list[dict] = [] + row_index = 0 + y = min_y + while y < max_y: + col_index = 0 + x = min_x + while x < max_x: + cell_polygon = [ + (x, y), + (x + step_m, y), + (x + step_m, y + step_m), + (x, y + step_m), + ] + if _cell_intersects_polygon(cell_polygon, projected_polygon): + payloads.append( + _build_cell_payload( + location=location, + block_code=block_code, + chunk_size_sqm=chunk_size_sqm, + polygon=polygon, + cell_polygon=cell_polygon, + row_index=row_index, + col_index=col_index, + ) + ) + col_index += 1 + x += step_m + row_index += 1 + y += step_m + return payloads + + +def _build_cell_payload( + *, + location: SoilLocation, + block_code: str, + chunk_size_sqm: int, + polygon: list[GeoPoint], + cell_polygon: list[tuple[float, float]], + row_index: int, + col_index: int, +) -> dict: + closed_polygon = cell_polygon + [cell_polygon[0]] + geometry_coordinates = [] + for x, y in closed_polygon: + lat, lon = unproject_point(x, y, polygon) + geometry_coordinates.append( + [quantize_coordinate(lon), quantize_coordinate(lat)] + ) + + centroid_x = sum(point[0] for point in cell_polygon) / len(cell_polygon) + centroid_y = sum(point[1] for point in cell_polygon) / len(cell_polygon) + centroid_lat, centroid_lon = unproject_point(centroid_x, centroid_y, polygon) + return { + "cell_code": build_analysis_cell_code( + location_id=location.id, + block_code=block_code, + chunk_size_sqm=chunk_size_sqm, + row_index=row_index, + col_index=col_index, + ), + "geometry": { + "type": "Polygon", + "coordinates": [geometry_coordinates], + }, + "centroid_lat": quantize_coordinate(centroid_lat), + "centroid_lon": quantize_coordinate(centroid_lon), + } + + +def build_analysis_cell_code( + *, + location_id: int | None, + block_code: str, + chunk_size_sqm: int, + row_index: int, + col_index: int, +) -> str: + block_segment = block_code or "farm" + location_segment = location_id if location_id is not None else "new" + return ( + f"loc-{location_segment}__" + f"block-{block_segment}__" + f"chunk-{chunk_size_sqm}__" + f"r{row_index:04d}c{col_index:04d}" + ) + + +def _resolve_boundary( + *, + location: SoilLocation, + boundary: dict | list | None, + block_subdivision: BlockSubdivision | None, +) -> dict | list: + if boundary: + return boundary + if block_subdivision is not None and block_subdivision.source_boundary: + return block_subdivision.source_boundary + if location.farm_boundary: + return location.farm_boundary + raise ValueError("هیچ boundary معتبری برای ساخت analysis grid پیدا نشد.") + + +def _cell_intersects_polygon( + cell_polygon: list[tuple[float, float]], + polygon: list[tuple[float, float]], +) -> bool: + if any(point_in_polygon(point, polygon) for point in cell_polygon): + return True + + for polygon_point in polygon: + if _point_in_rect(polygon_point, cell_polygon): + return True + + cell_edges = _polygon_edges(cell_polygon) + polygon_edges = _polygon_edges(polygon) + for edge_a in cell_edges: + for edge_b in polygon_edges: + if _segments_intersect(edge_a[0], edge_a[1], edge_b[0], edge_b[1]): + return True + return False + + +def _point_in_rect(point: tuple[float, float], rect: list[tuple[float, float]]) -> bool: + xs = [vertex[0] for vertex in rect] + ys = [vertex[1] for vertex in rect] + return min(xs) <= point[0] <= max(xs) and min(ys) <= point[1] <= max(ys) + + +def _polygon_edges(points: list[tuple[float, float]]) -> list[tuple[tuple[float, float], tuple[float, float]]]: + closed = points + [points[0]] + return [ + (closed[index], closed[index + 1]) + for index in range(len(points)) + ] + + +def _segments_intersect( + p1: tuple[float, float], + p2: tuple[float, float], + q1: tuple[float, float], + q2: tuple[float, float], +) -> bool: + o1 = _orientation(p1, p2, q1) + o2 = _orientation(p1, p2, q2) + o3 = _orientation(q1, q2, p1) + o4 = _orientation(q1, q2, p2) + + if o1 != o2 and o3 != o4: + return True + if o1 == 0 and _on_segment(p1, q1, p2): + return True + if o2 == 0 and _on_segment(p1, q2, p2): + return True + if o3 == 0 and _on_segment(q1, p1, q2): + return True + if o4 == 0 and _on_segment(q1, p2, q2): + return True + return False + + +def _orientation(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float]) -> int: + value = ((b[1] - a[1]) * (c[0] - b[0])) - ((b[0] - a[0]) * (c[1] - b[1])) + if abs(value) < 1e-9: + return 0 + return 1 if value > 0 else 2 + + +def _on_segment(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float]) -> bool: + return ( + min(a[0], c[0]) <= b[0] <= max(a[0], c[0]) + and min(a[1], c[1]) <= b[1] <= max(a[1], c[1]) + ) + + +def _update_grid_summary_metadata( + *, + location: SoilLocation, + block_code: str, + chunk_size_sqm: int, + total_count: int, + block_subdivision: BlockSubdivision | None, +) -> None: + if block_subdivision is not None: + metadata = dict(block_subdivision.metadata or {}) + metadata["analysis_grid"] = { + "chunk_size_sqm": chunk_size_sqm, + "cell_count": total_count, + } + block_subdivision.metadata = metadata + block_subdivision.save(update_fields=["metadata", "updated_at"]) + + layout = dict(location.block_layout or {}) + blocks = list(layout.get("blocks") or []) + for block in blocks: + if block.get("block_code") == block_code: + block["analysis_grid_summary"] = { + "chunk_size_sqm": chunk_size_sqm, + "cell_count": total_count, + } + break + else: + if not block_code: + layout["analysis_grid_summary"] = { + "chunk_size_sqm": chunk_size_sqm, + "cell_count": total_count, + } + + if blocks: + layout["blocks"] = blocks + location.block_layout = layout + location.save(update_fields=["block_layout", "updated_at"]) diff --git a/Modules/Ai/location_data/management/__init__.py b/Modules/Ai/location_data/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/location_data/management/commands/__init__.py b/Modules/Ai/location_data/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/location_data/management/commands/rename_soil_data_label.py b/Modules/Ai/location_data/management/commands/rename_soil_data_label.py new file mode 100644 index 0000000..7e447a8 --- /dev/null +++ b/Modules/Ai/location_data/management/commands/rename_soil_data_label.py @@ -0,0 +1,32 @@ +""" +Management command: اجرای یک‌بار rename اپ label از soil_data به location_data در DB. +این دستور را یک بار قبل از اجرای migrate اجرا کنید: + python manage.py rename_soil_data_label + python manage.py migrate +""" +from django.core.management.base import BaseCommand +from django.db import connection + + +class Command(BaseCommand): + help = "Rename app label from soil_data to location_data in django_migrations and django_content_type" + + def handle(self, *args, **options): + with connection.cursor() as cursor: + cursor.execute( + "UPDATE django_migrations SET app = %s WHERE app = %s", + ["location_data", "soil_data"], + ) + migrations_updated = cursor.rowcount + cursor.execute( + "UPDATE django_content_type SET app_label = %s WHERE app_label = %s", + ["location_data", "soil_data"], + ) + content_types_updated = cursor.rowcount + + self.stdout.write( + self.style.SUCCESS( + f"Done. django_migrations rows updated: {migrations_updated}, " + f"django_content_type rows updated: {content_types_updated}" + ) + ) diff --git a/Modules/Ai/location_data/management/commands/repair_location_tables.py b/Modules/Ai/location_data/management/commands/repair_location_tables.py new file mode 100644 index 0000000..3ed6c76 --- /dev/null +++ b/Modules/Ai/location_data/management/commands/repair_location_tables.py @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand +from django.db import connection + + +class Command(BaseCommand): + help = "Rename legacy soil_data tables to location_data tables when needed" + + def handle(self, *args, **options): + table_map = { + "soil_data_soillocation": "location_data_soillocation", + "soil_data_soildepthdata": "location_data_soildepthdata", + } + + existing_tables = set(connection.introspection.table_names()) + renamed: list[str] = [] + + with connection.cursor() as cursor: + for old_name, new_name in table_map.items(): + if new_name in existing_tables: + continue + if old_name not in existing_tables: + continue + + cursor.execute(f"RENAME TABLE `{old_name}` TO `{new_name}`") + renamed.append(f"{old_name} -> {new_name}") + existing_tables.discard(old_name) + existing_tables.add(new_name) + + if renamed: + self.stdout.write( + self.style.SUCCESS("Renamed legacy tables: " + ", ".join(renamed)) + ) + return + + self.stdout.write("No legacy location_data tables needed repair.") diff --git a/Modules/Ai/location_data/migrations/0001_initial.py b/Modules/Ai/location_data/migrations/0001_initial.py new file mode 100644 index 0000000..e391044 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated manually for location_data + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SoilLocation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("latitude", models.DecimalField(db_index=True, decimal_places=6, help_text="عرض جغرافیایی (lat)", max_digits=9)), + ("longitude", models.DecimalField(db_index=True, decimal_places=6, help_text="طول جغرافیایی (lon)", max_digits=9)), + ("depth_0_5cm", models.JSONField(blank=True, help_text="داده‌های لایه ۰–۵ سانتی‌متر از API SoilGrids", null=True)), + ("depth_5_15cm", models.JSONField(blank=True, help_text="داده‌های لایه ۵–۱۵ سانتی‌متر از API SoilGrids", null=True)), + ("depth_15_30cm", models.JSONField(blank=True, help_text="داده‌های لایه ۱۵–۳۰ سانتی‌متر از API SoilGrids", null=True)), + ("task_id", models.CharField(blank=True, help_text="شناسه تسک Celery در حال پردازش", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["-updated_at"], + }, + ), + migrations.AddConstraint( + model_name="soillocation", + constraint=models.UniqueConstraint(fields=("latitude", "longitude"), name="soil_location_unique_lat_lon"), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0002_soildepthdata_refactor.py b/Modules/Ai/location_data/migrations/0002_soildepthdata_refactor.py new file mode 100644 index 0000000..9e07645 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0002_soildepthdata_refactor.py @@ -0,0 +1,77 @@ +# Generated manually: refactor to SoilDepthData table + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="SoilDepthData", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "depth_label", + models.CharField( + choices=[ + ("0-5cm", "۰–۵ سانتی‌متر"), + ("5-15cm", "۵–۱۵ سانتی‌متر"), + ("15-30cm", "۱۵–۳۰ سانتی‌متر"), + ], + db_index=True, + max_length=10, + ), + ), + ("bdod", models.FloatField(blank=True, null=True)), + ("cec", models.FloatField(blank=True, null=True)), + ("cfvo", models.FloatField(blank=True, null=True)), + ("clay", models.FloatField(blank=True, null=True)), + ("nitrogen", models.FloatField(blank=True, null=True)), + ("ocd", models.FloatField(blank=True, null=True)), + ("ocs", models.FloatField(blank=True, null=True)), + ("phh2o", models.FloatField(blank=True, null=True)), + ("sand", models.FloatField(blank=True, null=True)), + ("silt", models.FloatField(blank=True, null=True)), + ("soc", models.FloatField(blank=True, null=True)), + ("wv0010", models.FloatField(blank=True, null=True)), + ("wv0033", models.FloatField(blank=True, null=True)), + ("wv1500", models.FloatField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "soil_location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="depths", + to="location_data.soillocation", + ), + ), + ], + options={ + "ordering": ["soil_location", "depth_label"], + }, + ), + migrations.AddConstraint( + model_name="soildepthdata", + constraint=models.UniqueConstraint( + fields=("soil_location", "depth_label"), + name="soil_depth_unique_location_depth", + ), + ), + migrations.RemoveField( + model_name="soillocation", + name="depth_0_5cm", + ), + migrations.RemoveField( + model_name="soillocation", + name="depth_5_15cm", + ), + migrations.RemoveField( + model_name="soillocation", + name="depth_15_30cm", + ), + ] diff --git a/Modules/Ai/location_data/migrations/0002_soillocation_ideal_sensor_profile.py b/Modules/Ai/location_data/migrations/0002_soillocation_ideal_sensor_profile.py new file mode 100644 index 0000000..f301c51 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0002_soillocation_ideal_sensor_profile.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="soillocation", + name="ideal_sensor_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل ایده‌آل سنسورها برای این مزرعه/لوکیشن. " + 'نمونه: {"moisture": {"ideal": 0.65, "min": 0.50, "max": 0.80}}' + ), + ), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0003_rename_app_label.py b/Modules/Ai/location_data/migrations/0003_rename_app_label.py new file mode 100644 index 0000000..281057c --- /dev/null +++ b/Modules/Ai/location_data/migrations/0003_rename_app_label.py @@ -0,0 +1,17 @@ +from django.db import migrations +from django.db import migrations +from django.db import migrations + + +class Migration(migrations.Migration): + """ + نشانگر تغییر اپ label از soil_data به location_data. + پیش از اجرای این migration، دستور زیر را اجرا کنید: + python manage.py rename_soil_data_label + """ + + dependencies = [ + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [] diff --git a/Modules/Ai/location_data/migrations/0004_soillocation_farm_boundary.py b/Modules/Ai/location_data/migrations/0004_soillocation_farm_boundary.py new file mode 100644 index 0000000..7c11a31 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0004_soillocation_farm_boundary.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0003_rename_app_label"), + ] + + operations = [ + migrations.AddField( + model_name="soillocation", + name="farm_boundary", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "مرز مزرعه برای درخواست‌های سنجش‌ازدور. " + 'می‌تواند GeoJSON polygon یا bbox مثل {"type": "Polygon", "coordinates": [...]} باشد.' + ), + ), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0005_merge_20260327_0840.py b/Modules/Ai/location_data/migrations/0005_merge_20260327_0840.py new file mode 100644 index 0000000..df18cbc --- /dev/null +++ b/Modules/Ai/location_data/migrations/0005_merge_20260327_0840.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.15 on 2026-03-27 08:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('location_data', '0002_soillocation_ideal_sensor_profile'), + ('location_data', '0004_soillocation_farm_boundary'), + ] + + operations = [ + ] diff --git a/Modules/Ai/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py b/Modules/Ai/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py new file mode 100644 index 0000000..96dce3c --- /dev/null +++ b/Modules/Ai/location_data/migrations/0006_remove_soillocation_ideal_sensor_profile.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0005_merge_20260327_0840"), + ] + + operations = [ + migrations.RemoveField( + model_name="soillocation", + name="ideal_sensor_profile", + ), + ] diff --git a/Modules/Ai/location_data/migrations/0007_ndviobservation.py b/Modules/Ai/location_data/migrations/0007_ndviobservation.py new file mode 100644 index 0000000..50699e4 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0007_ndviobservation.py @@ -0,0 +1,46 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0006_remove_soillocation_ideal_sensor_profile"), + ] + + operations = [ + migrations.CreateModel( + name="NdviObservation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("observation_date", models.DateField(db_index=True)), + ("mean_ndvi", models.FloatField()), + ("ndvi_map", models.JSONField(blank=True, default=dict)), + ("vegetation_health_class", models.CharField(max_length=64)), + ("satellite_source", models.CharField(default="sentinel-2", max_length=64)), + ("cloud_cover", models.FloatField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "location", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ndvi_observations", + to="location_data.soillocation", + ), + ), + ], + options={ + "verbose_name": "NDVI Observation", + "verbose_name_plural": "NDVI Observations", + "db_table": "dashboard_data_ndviobservation", + "ordering": ["-observation_date", "-created_at"], + "constraints": [ + models.UniqueConstraint( + fields=("location", "observation_date", "satellite_source"), + name="ndvi_unique_location_date_source", + ), + ], + }, + ), + ] diff --git a/Modules/Ai/location_data/migrations/0008_soillocation_block_layout.py b/Modules/Ai/location_data/migrations/0008_soillocation_block_layout.py new file mode 100644 index 0000000..ca15298 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0008_soillocation_block_layout.py @@ -0,0 +1,45 @@ +from django.db import migrations, models + + +def build_default_layout(): + return { + "input_block_count": 1, + "default_full_farm": True, + "algorithm_status": "pending", + "blocks": [ + { + "block_code": "block-1", + "order": 1, + "source": "default", + "needs_subdivision": None, + "sub_blocks": [], + } + ], + } + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0007_ndviobservation"), + ] + + operations = [ + migrations.AddField( + model_name="soillocation", + name="block_layout", + field=models.JSONField( + blank=True, + default=build_default_layout, + help_text="ساختار بلوک‌های زمین. به‌صورت پیش‌فرض کل زمین یک بلوک است و بعداً الگوریتم می‌تواند برای هر بلوک زیر‌بلوک تعریف کند.", + ), + ), + migrations.AddField( + model_name="soillocation", + name="input_block_count", + field=models.PositiveIntegerField( + default=1, + help_text="تعداد بلوک‌های اولیه‌ای که کشاورز برای زمین ثبت می‌کند.", + ), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0009_blocksubdivision.py b/Modules/Ai/location_data/migrations/0009_blocksubdivision.py new file mode 100644 index 0000000..e3a5eea --- /dev/null +++ b/Modules/Ai/location_data/migrations/0009_blocksubdivision.py @@ -0,0 +1,38 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0008_soillocation_block_layout"), + ] + + operations = [ + migrations.CreateModel( + name="BlockSubdivision", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("block_code", models.CharField(help_text="شناسه بلوکی که این خردسازی برای آن انجام شده است.", max_length=64)), + ("source_boundary", models.JSONField(blank=True, default=dict, help_text="مرز همان بلوکی که به سرویس subdivision داده شده است.")), + ("chunk_size_sqm", models.PositiveIntegerField(default=100, help_text="اندازه هر chunk به متر مربع.")), + ("grid_points", models.JSONField(blank=True, default=list, help_text="نقاط اولیه شبکه داخل مرز بلوک.")), + ("centroid_points", models.JSONField(blank=True, default=list, help_text="مراکز نهایی بخش‌های خردشده.")), + ("grid_point_count", models.PositiveIntegerField(default=0)), + ("centroid_count", models.PositiveIntegerField(default=0)), + ("status", models.CharField(default="created", help_text="وضعیت تولید subdivision برای این بلوک.", max_length=32)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("soil_location", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="block_subdivisions", to="location_data.soillocation")), + ], + options={ + "ordering": ["soil_location", "block_code", "-updated_at"], + "verbose_name": "خردسازی بلوک", + "verbose_name_plural": "خردسازی بلوک‌ها", + }, + ), + migrations.AddConstraint( + model_name="blocksubdivision", + constraint=models.UniqueConstraint(fields=("soil_location", "block_code"), name="location_block_subdivision_unique_location_block_code"), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0010_blocksubdivision_elbow_plot.py b/Modules/Ai/location_data/migrations/0010_blocksubdivision_elbow_plot.py new file mode 100644 index 0000000..57847a9 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0010_blocksubdivision_elbow_plot.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0009_blocksubdivision"), + ] + + operations = [ + migrations.AddField( + model_name="blocksubdivision", + name="elbow_plot", + field=models.ImageField( + blank=True, + help_text="تصویر نمودار elbow برای انتخاب تعداد بهینه خوشه‌ها.", + null=True, + upload_to="location_data/elbow_plots/", + ), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0011_remote_sensing_models.py b/Modules/Ai/location_data/migrations/0011_remote_sensing_models.py new file mode 100644 index 0000000..4eb659f --- /dev/null +++ b/Modules/Ai/location_data/migrations/0011_remote_sensing_models.py @@ -0,0 +1,110 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0010_blocksubdivision_elbow_plot"), + ] + + operations = [ + migrations.CreateModel( + name="AnalysisGridCell", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("block_code", models.CharField(blank=True, db_index=True, default="", help_text="شناسه بلوکی که این سلول به آن تعلق دارد.", max_length=64)), + ("cell_code", models.CharField(help_text="شناسه یکتای سلول تحلیل.", max_length=128, unique=True)), + ("chunk_size_sqm", models.PositiveIntegerField(db_index=True, default=900, help_text="اندازه سلول تحلیل به متر مربع.")), + ("geometry", models.JSONField(blank=True, default=dict, help_text="هندسه سلول به صورت GeoJSON polygon یا ساختار مشابه.")), + ("centroid_lat", models.DecimalField(db_index=True, decimal_places=6, help_text="عرض جغرافیایی مرکز سلول.", max_digits=9)), + ("centroid_lon", models.DecimalField(db_index=True, decimal_places=6, help_text="طول جغرافیایی مرکز سلول.", max_digits=9)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("block_subdivision", models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name="analysis_grid_cells", to="location_data.blocksubdivision")), + ("soil_location", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="analysis_grid_cells", to="location_data.soillocation")), + ], + options={ + "verbose_name": "analysis grid cell", + "verbose_name_plural": "analysis grid cells", + "ordering": ["soil_location", "block_code", "cell_code"], + }, + ), + migrations.CreateModel( + name="RemoteSensingRun", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("block_code", models.CharField(blank=True, db_index=True, default="", help_text="شناسه بلوکی که این run برای آن اجرا شده است.", max_length=64)), + ("provider", models.CharField(default="openeo", help_text="ارائه‌دهنده داده سنجش‌ازدور.", max_length=64)), + ("chunk_size_sqm", models.PositiveIntegerField(default=900, help_text="اندازه هر سلول تحلیل به متر مربع.")), + ("temporal_start", models.DateField(blank=True, null=True)), + ("temporal_end", models.DateField(blank=True, null=True)), + ("status", models.CharField(choices=[("pending", "Pending"), ("running", "Running"), ("success", "Success"), ("failure", "Failure")], db_index=True, default="pending", max_length=16)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("error_message", models.TextField(blank=True, default="")), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("finished_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("block_subdivision", models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name="remote_sensing_runs", to="location_data.blocksubdivision")), + ("soil_location", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="remote_sensing_runs", to="location_data.soillocation")), + ], + options={ + "verbose_name": "remote sensing run", + "verbose_name_plural": "remote sensing runs", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="AnalysisGridObservation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("temporal_start", models.DateField(db_index=True)), + ("temporal_end", models.DateField(db_index=True)), + ("ndvi", models.FloatField(blank=True, null=True)), + ("ndwi", models.FloatField(blank=True, null=True)), + ("lst_c", models.FloatField(blank=True, null=True)), + ("soil_vv", models.FloatField(blank=True, null=True)), + ("soil_vv_db", models.FloatField(blank=True, null=True)), + ("dem_m", models.FloatField(blank=True, null=True)), + ("slope_deg", models.FloatField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("cell", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="observations", to="location_data.analysisgridcell")), + ("run", models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name="observations", to="location_data.remotesensingrun")), + ], + options={ + "verbose_name": "analysis grid observation", + "verbose_name_plural": "analysis grid observations", + "ordering": ["-temporal_start", "-temporal_end", "-id"], + }, + ), + migrations.AddIndex( + model_name="analysisgridcell", + index=models.Index(fields=["soil_location", "block_code"], name="grid_cell_loc_block_idx"), + ), + migrations.AddIndex( + model_name="analysisgridcell", + index=models.Index(fields=["soil_location", "chunk_size_sqm"], name="grid_cell_loc_chunk_idx"), + ), + migrations.AddIndex( + model_name="remotesensingrun", + index=models.Index(fields=["soil_location", "status", "created_at"], name="rs_run_loc_status_created_idx"), + ), + migrations.AddIndex( + model_name="remotesensingrun", + index=models.Index(fields=["block_code", "created_at"], name="rs_run_block_created_idx"), + ), + migrations.AddConstraint( + model_name="analysisgridobservation", + constraint=models.UniqueConstraint(fields=("cell", "temporal_start", "temporal_end"), name="grid_obs_unique_cell_temporal_range"), + ), + migrations.AddIndex( + model_name="analysisgridobservation", + index=models.Index(fields=["cell", "temporal_start", "temporal_end"], name="grid_obs_cell_temporal_idx"), + ), + migrations.AddIndex( + model_name="analysisgridobservation", + index=models.Index(fields=["temporal_start", "temporal_end"], name="grid_obs_temporal_idx"), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0012_remote_sensing_subdivision_models.py b/Modules/Ai/location_data/migrations/0012_remote_sensing_subdivision_models.py new file mode 100644 index 0000000..33946a7 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0012_remote_sensing_subdivision_models.py @@ -0,0 +1,65 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0011_remote_sensing_models"), + ] + + operations = [ + migrations.CreateModel( + name="RemoteSensingSubdivisionResult", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("block_code", models.CharField(blank=True, db_index=True, default="", max_length=64)), + ("chunk_size_sqm", models.PositiveIntegerField(default=900)), + ("temporal_start", models.DateField(db_index=True)), + ("temporal_end", models.DateField(db_index=True)), + ("cluster_count", models.PositiveIntegerField(default=0)), + ("selected_features", models.JSONField(blank=True, default=list)), + ("skipped_cell_codes", models.JSONField(blank=True, default=list)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("block_subdivision", models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, related_name="remote_sensing_subdivision_results", to="location_data.blocksubdivision")), + ("run", models.OneToOneField(on_delete=models.deletion.CASCADE, related_name="subdivision_result", to="location_data.remotesensingrun")), + ("soil_location", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="remote_sensing_subdivision_results", to="location_data.soillocation")), + ], + options={ + "verbose_name": "remote sensing subdivision result", + "verbose_name_plural": "remote sensing subdivision results", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="RemoteSensingClusterAssignment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("cluster_label", models.PositiveIntegerField(db_index=True)), + ("raw_feature_values", models.JSONField(blank=True, default=dict)), + ("scaled_feature_values", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("cell", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="cluster_assignments", to="location_data.analysisgridcell")), + ("result", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="assignments", to="location_data.remotesensingsubdivisionresult")), + ], + options={ + "verbose_name": "remote sensing cluster assignment", + "verbose_name_plural": "remote sensing cluster assignments", + "ordering": ["cluster_label", "cell__cell_code"], + }, + ), + migrations.AddIndex( + model_name="remotesensingsubdivisionresult", + index=models.Index(fields=["soil_location", "block_code", "temporal_start", "temporal_end"], name="rs_subdiv_result_lookup_idx"), + ), + migrations.AddConstraint( + model_name="remotesensingclusterassignment", + constraint=models.UniqueConstraint(fields=("result", "cell"), name="rs_cluster_assign_unique_result_cell"), + ), + migrations.AddIndex( + model_name="remotesensingclusterassignment", + index=models.Index(fields=["result", "cluster_label"], name="rs_cluster_assign_result_label_idx"), + ), + ] diff --git a/Modules/Ai/location_data/migrations/0013_remove_soildepthdata.py b/Modules/Ai/location_data/migrations/0013_remove_soildepthdata.py new file mode 100644 index 0000000..d245bb2 --- /dev/null +++ b/Modules/Ai/location_data/migrations/0013_remove_soildepthdata.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("location_data", "0012_remote_sensing_subdivision_models"), + ] + + operations = [ + migrations.DeleteModel( + name="SoilDepthData", + ), + ] diff --git a/Modules/Ai/location_data/migrations/__init__.py b/Modules/Ai/location_data/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/location_data/models.py b/Modules/Ai/location_data/models.py new file mode 100644 index 0000000..ca83008 --- /dev/null +++ b/Modules/Ai/location_data/models.py @@ -0,0 +1,523 @@ +from django.db import models + + +def build_block_layout(block_count: int = 1, blocks: list[dict] | None = None) -> dict: + normalized_blocks = [] + if blocks: + for index, block in enumerate(blocks): + normalized_blocks.append( + { + "block_code": str(block.get("block_code") or f"block-{index + 1}").strip(), + "order": int(block.get("order") or index + 1), + "source": "input", + "boundary": block.get("boundary") or {}, + "needs_subdivision": None, + "sub_blocks": [], + } + ) + else: + normalized_count = max(int(block_count or 1), 1) + for index in range(normalized_count): + normalized_blocks.append( + { + "block_code": f"block-{index + 1}", + "order": index + 1, + "source": "input" if normalized_count > 1 else "default", + "boundary": {}, + "needs_subdivision": None, + "sub_blocks": [], + } + ) + + normalized_count = len(normalized_blocks) if normalized_blocks else max(int(block_count or 1), 1) + + return { + "input_block_count": normalized_count, + "default_full_farm": normalized_count == 1, + "algorithm_status": "pending", + "blocks": normalized_blocks, + } + + +class SoilLocation(models.Model): + """ + مرکز زمین و مرز مزرعه/بلوک‌های تعریف‌شده توسط کشاورز. + """ + + latitude = models.DecimalField( + max_digits=9, + decimal_places=6, + db_index=True, + help_text="عرض جغرافیایی مرکز زمین (lat)", + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + db_index=True, + help_text="طول جغرافیایی مرکز زمین (lon)", + ) + task_id = models.CharField( + max_length=255, + blank=True, + help_text="شناسه تسک Celery در حال پردازش", + ) + + farm_boundary = models.JSONField( + default=dict, + blank=True, + help_text=( + "مرز مزرعه برای درخواست‌های سنجش‌ازدور. " + 'می‌تواند GeoJSON polygon یا bbox مثل {"type": "Polygon", "coordinates": [...]} باشد.' + ), + ) + input_block_count = models.PositiveIntegerField( + default=1, + help_text="تعداد بلوک‌های اولیه‌ای که کشاورز برای زمین ثبت می‌کند.", + ) + block_layout = models.JSONField( + default=build_block_layout, + blank=True, + help_text=( + "ساختار بلوک‌های زمین. به‌صورت پیش‌فرض کل زمین یک بلوک است و " + "بعداً الگوریتم می‌تواند برای هر بلوک زیر‌بلوک تعریف کند." + ), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["latitude", "longitude"], + name="soil_location_unique_lat_lon", + ) + ] + ordering = ["-updated_at"] + verbose_name = "مرکز زمین" + verbose_name_plural = "مراکز زمین" + + def __str__(self): + return f"SoilLocation({self.latitude}, {self.longitude})" + + @property + def center_latitude(self): + return self.latitude + + @property + def center_longitude(self): + return self.longitude + + @property + def is_complete(self): + """آیا حداقل یک run کامل remote sensing برای این location وجود دارد؟""" + return self.remote_sensing_runs.filter(status="success").exists() + + def set_input_block_count(self, block_count: int = 1, blocks: list[dict] | None = None): + normalized_count = len(blocks) if blocks else max(int(block_count or 1), 1) + self.input_block_count = normalized_count + self.block_layout = build_block_layout(normalized_count, blocks=blocks) + + def save(self, *args, **kwargs): + if not self.input_block_count: + self.input_block_count = 1 + if not self.block_layout: + self.block_layout = build_block_layout(self.input_block_count) + super().save(*args, **kwargs) + + +class BlockSubdivision(models.Model): + """ + نتیجه خردسازی یک بلوک برای یک SoilLocation. + grid_points نقاط اولیه شبکه هستند و centroid_points مراکز نهایی بخش‌ها. + """ + + soil_location = models.ForeignKey( + SoilLocation, + on_delete=models.CASCADE, + related_name="block_subdivisions", + ) + block_code = models.CharField( + max_length=64, + help_text="شناسه بلوکی که این خردسازی برای آن انجام شده است.", + ) + source_boundary = models.JSONField( + default=dict, + blank=True, + help_text="مرز همان بلوکی که به سرویس subdivision داده شده است.", + ) + chunk_size_sqm = models.PositiveIntegerField( + default=100, + help_text="اندازه هر chunk به متر مربع.", + ) + grid_points = models.JSONField( + default=list, + blank=True, + help_text="نقاط اولیه شبکه داخل مرز بلوک.", + ) + centroid_points = models.JSONField( + default=list, + blank=True, + help_text="مراکز نهایی بخش‌های خردشده.", + ) + grid_point_count = models.PositiveIntegerField(default=0) + centroid_count = models.PositiveIntegerField(default=0) + elbow_plot = models.ImageField( + upload_to="location_data/elbow_plots/", + null=True, + blank=True, + help_text="تصویر نمودار elbow برای انتخاب تعداد بهینه خوشه‌ها.", + ) + status = models.CharField( + max_length=32, + default="created", + help_text="وضعیت تولید subdivision برای این بلوک.", + ) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["soil_location", "block_code"], + name="location_block_subdivision_unique_location_block_code", + ) + ] + ordering = ["soil_location", "block_code", "-updated_at"] + verbose_name = "خردسازی بلوک" + verbose_name_plural = "خردسازی بلوک‌ها" + + def __str__(self): + return f"BlockSubdivision({self.soil_location_id}, {self.block_code})" + + +class RemoteSensingRun(models.Model): + STATUS_PENDING = "pending" + STATUS_RUNNING = "running" + STATUS_SUCCESS = "success" + STATUS_FAILURE = "failure" + STATUS_CHOICES = [ + (STATUS_PENDING, "Pending"), + (STATUS_RUNNING, "Running"), + (STATUS_SUCCESS, "Success"), + (STATUS_FAILURE, "Failure"), + ] + + soil_location = models.ForeignKey( + SoilLocation, + on_delete=models.CASCADE, + related_name="remote_sensing_runs", + ) + block_subdivision = models.ForeignKey( + BlockSubdivision, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="remote_sensing_runs", + ) + block_code = models.CharField( + max_length=64, + blank=True, + default="", + db_index=True, + help_text="شناسه بلوکی که این run برای آن اجرا شده است.", + ) + provider = models.CharField( + max_length=64, + default="openeo", + help_text="ارائه‌دهنده داده سنجش‌ازدور.", + ) + chunk_size_sqm = models.PositiveIntegerField( + default=900, + help_text="اندازه هر سلول تحلیل به متر مربع.", + ) + temporal_start = models.DateField(null=True, blank=True) + temporal_end = models.DateField(null=True, blank=True) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_PENDING, + db_index=True, + ) + metadata = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, default="") + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [ + models.Index( + fields=["soil_location", "status", "created_at"], + name="rs_run_loc_status_created_idx", + ), + models.Index( + fields=["block_code", "created_at"], + name="rs_run_block_created_idx", + ), + ] + verbose_name = "remote sensing run" + verbose_name_plural = "remote sensing runs" + + def __str__(self): + block_text = self.block_code or "farm" + return f"RemoteSensingRun({self.soil_location_id}, {block_text}, {self.status})" + + @property + def normalized_status(self) -> str: + """ + Return the client-facing lifecycle status while keeping legacy DB values stable. + """ + if self.status == self.STATUS_SUCCESS: + return "completed" + if self.status == self.STATUS_FAILURE: + return "failed" + return self.status + + +class AnalysisGridCell(models.Model): + soil_location = models.ForeignKey( + SoilLocation, + on_delete=models.CASCADE, + related_name="analysis_grid_cells", + ) + block_subdivision = models.ForeignKey( + BlockSubdivision, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="analysis_grid_cells", + ) + block_code = models.CharField( + max_length=64, + blank=True, + default="", + db_index=True, + help_text="شناسه بلوکی که این سلول به آن تعلق دارد.", + ) + cell_code = models.CharField( + max_length=128, + unique=True, + help_text="شناسه یکتای سلول تحلیل.", + ) + chunk_size_sqm = models.PositiveIntegerField( + default=900, + db_index=True, + help_text="اندازه سلول تحلیل به متر مربع.", + ) + geometry = models.JSONField( + default=dict, + blank=True, + help_text="هندسه سلول به صورت GeoJSON polygon یا ساختار مشابه.", + ) + centroid_lat = models.DecimalField( + max_digits=9, + decimal_places=6, + db_index=True, + help_text="عرض جغرافیایی مرکز سلول.", + ) + centroid_lon = models.DecimalField( + max_digits=9, + decimal_places=6, + db_index=True, + help_text="طول جغرافیایی مرکز سلول.", + ) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["soil_location", "block_code", "cell_code"] + indexes = [ + models.Index( + fields=["soil_location", "block_code"], + name="grid_cell_loc_block_idx", + ), + models.Index( + fields=["soil_location", "chunk_size_sqm"], + name="grid_cell_loc_chunk_idx", + ), + ] + verbose_name = "analysis grid cell" + verbose_name_plural = "analysis grid cells" + + def __str__(self): + return f"AnalysisGridCell({self.cell_code})" + + +class AnalysisGridObservation(models.Model): + cell = models.ForeignKey( + AnalysisGridCell, + on_delete=models.CASCADE, + related_name="observations", + ) + run = models.ForeignKey( + RemoteSensingRun, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="observations", + ) + temporal_start = models.DateField(db_index=True) + temporal_end = models.DateField(db_index=True) + ndvi = models.FloatField(null=True, blank=True) + ndwi = models.FloatField(null=True, blank=True) + lst_c = models.FloatField(null=True, blank=True) + soil_vv = models.FloatField(null=True, blank=True) + soil_vv_db = models.FloatField(null=True, blank=True) + dem_m = models.FloatField(null=True, blank=True) + slope_deg = models.FloatField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-temporal_start", "-temporal_end", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["cell", "temporal_start", "temporal_end"], + name="grid_obs_unique_cell_temporal_range", + ) + ] + indexes = [ + models.Index( + fields=["cell", "temporal_start", "temporal_end"], + name="grid_obs_cell_temporal_idx", + ), + models.Index( + fields=["temporal_start", "temporal_end"], + name="grid_obs_temporal_idx", + ), + ] + verbose_name = "analysis grid observation" + verbose_name_plural = "analysis grid observations" + + def __str__(self): + return ( + f"AnalysisGridObservation({self.cell_id}, " + f"{self.temporal_start}, {self.temporal_end})" + ) + + +class RemoteSensingSubdivisionResult(models.Model): + soil_location = models.ForeignKey( + SoilLocation, + on_delete=models.CASCADE, + related_name="remote_sensing_subdivision_results", + ) + run = models.OneToOneField( + RemoteSensingRun, + on_delete=models.CASCADE, + related_name="subdivision_result", + ) + block_subdivision = models.ForeignKey( + BlockSubdivision, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="remote_sensing_subdivision_results", + ) + block_code = models.CharField( + max_length=64, + blank=True, + default="", + db_index=True, + ) + chunk_size_sqm = models.PositiveIntegerField(default=900) + temporal_start = models.DateField(db_index=True) + temporal_end = models.DateField(db_index=True) + cluster_count = models.PositiveIntegerField(default=0) + selected_features = models.JSONField(default=list, blank=True) + skipped_cell_codes = models.JSONField(default=list, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [ + models.Index( + fields=["soil_location", "block_code", "temporal_start", "temporal_end"], + name="rs_subdiv_result_lookup_idx", + ) + ] + verbose_name = "remote sensing subdivision result" + verbose_name_plural = "remote sensing subdivision results" + + def __str__(self): + return ( + f"RemoteSensingSubdivisionResult({self.soil_location_id}, " + f"{self.block_code or 'farm'}, clusters={self.cluster_count})" + ) + + +class RemoteSensingClusterAssignment(models.Model): + result = models.ForeignKey( + RemoteSensingSubdivisionResult, + on_delete=models.CASCADE, + related_name="assignments", + ) + cell = models.ForeignKey( + AnalysisGridCell, + on_delete=models.CASCADE, + related_name="cluster_assignments", + ) + cluster_label = models.PositiveIntegerField(db_index=True) + raw_feature_values = models.JSONField(default=dict, blank=True) + scaled_feature_values = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["cluster_label", "cell__cell_code"] + constraints = [ + models.UniqueConstraint( + fields=["result", "cell"], + name="rs_cluster_assign_unique_result_cell", + ) + ] + indexes = [ + models.Index( + fields=["result", "cluster_label"], + name="rs_cluster_assign_result_label_idx", + ) + ] + verbose_name = "remote sensing cluster assignment" + verbose_name_plural = "remote sensing cluster assignments" + + def __str__(self): + return f"RemoteSensingClusterAssignment({self.result_id}, {self.cell_id}, {self.cluster_label})" + + + + +class NdviObservation(models.Model): + location = models.ForeignKey( + SoilLocation, + on_delete=models.CASCADE, + related_name="ndvi_observations", + ) + observation_date = models.DateField(db_index=True) + mean_ndvi = models.FloatField() + ndvi_map = models.JSONField(default=dict, blank=True) + vegetation_health_class = models.CharField(max_length=64) + satellite_source = models.CharField(max_length=64, default="sentinel-2") + cloud_cover = models.FloatField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = "dashboard_data_ndviobservation" + ordering = ["-observation_date", "-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["location", "observation_date", "satellite_source"], + name="ndvi_unique_location_date_source", + ) + ] + verbose_name = "NDVI Observation" + verbose_name_plural = "NDVI Observations" + + def __str__(self): + return f"NDVI {self.location_id} {self.observation_date} {self.satellite_source}" diff --git a/Modules/Ai/location_data/ndvi.py b/Modules/Ai/location_data/ndvi.py new file mode 100644 index 0000000..6ecbe7d --- /dev/null +++ b/Modules/Ai/location_data/ndvi.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any + +from farm_data.models import SensorData +from .remote_sensing import fetch_or_get_ndvi_observation + + +def _ndvi_explanation(observation, ai_bundle: dict | None = None) -> str: + ai_bundle = ai_bundle or {} + ai_payload = ai_bundle.get("ndviHealthCard", {}) if isinstance(ai_bundle, dict) else {} + explanation = ai_payload.get("explanation") + if isinstance(explanation, str) and explanation.strip(): + return explanation.strip() + return ( + f"میانگین NDVI مزرعه {observation.mean_ndvi} ثبت شده و کلاس سلامت پوشش گیاهی " + f"در وضعیت {observation.vegetation_health_class} قرار دارد." + ) + + +def _build_ndvi_health_card(location: Any, ai_bundle: dict | None = None) -> dict[str, Any]: + if location is None: + return { + "mean_ndvi": None, + "ndvi_map": {}, + "vegetation_health_class": None, + "observation_date": None, + "satellite_source": None, + "healthData": [], + } + + observation = fetch_or_get_ndvi_observation(location) + if observation is None: + return { + "mean_ndvi": None, + "ndvi_map": {}, + "vegetation_health_class": "Unavailable", + "observation_date": None, + "satellite_source": None, + "healthData": [ + { + "title": "وضعیت NDVI", + "value": "داده ماهواره‌ای موجود نیست", + "color": "warning", + "icon": "tabler-satellite-off", + }, + ], + } + + mean_value = round(observation.mean_ndvi, 2) + vegetation_class = observation.vegetation_health_class + return { + "ndviIndex": mean_value, + "mean_ndvi": mean_value, + "ndvi_map": observation.ndvi_map, + "vegetation_health_class": vegetation_class, + "observation_date": observation.observation_date.isoformat(), + "satellite_source": observation.satellite_source, + "healthData": [ + { + "title": "سلامت پوشش گیاهی", + "value": vegetation_class, + "color": "success" if mean_value > 0.6 else "warning" if mean_value >= 0.4 else "error", + "icon": "tabler-plant", + }, + { + "title": "تاریخ مشاهده", + "value": observation.observation_date.isoformat(), + "color": "info", + "icon": "tabler-calendar", + }, + { + "title": "تفسیر", + "value": _ndvi_explanation(observation, ai_bundle=ai_bundle), + "color": "primary", + "icon": "tabler-message-2", + }, + ], + } + + +class NdviHealthService: + def get_ndvi_health(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + + return _build_ndvi_health_card(sensor.center_location, ai_bundle=None) diff --git a/Modules/Ai/location_data/openeo_service.py b/Modules/Ai/location_data/openeo_service.py new file mode 100644 index 0000000..e024e8c --- /dev/null +++ b/Modules/Ai/location_data/openeo_service.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +import math +import os +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Any + +from .models import AnalysisGridCell + + +DEFAULT_OPENEO_BACKEND_URL = "https://openeofed.dataspace.copernicus.eu" +DEFAULT_OPENEO_PROVIDER = "openeo" + +SENTINEL2_COLLECTION = "SENTINEL2_L2A" +SENTINEL3_LST_COLLECTION = "SENTINEL3_SLSTR_L2_LST" +SENTINEL1_COLLECTION = "SENTINEL1_GRD" +COPERNICUS_DEM_COLLECTION = "COPERNICUS_30" + +VALID_SCL_CLASSES = (4, 5, 6) +METRIC_NAMES = ( + "ndvi", + "ndwi", + "lst_c", + "soil_vv", + "soil_vv_db", + "dem_m", + "slope_deg", +) + + +class OpenEOServiceError(Exception): + """Base exception for openEO service failures.""" + + +class OpenEOAuthenticationError(OpenEOServiceError): + """Raised when authentication with the openEO backend fails.""" + + +class OpenEOExecutionError(OpenEOServiceError): + """Raised when a metric process graph can not be executed successfully.""" + + +@dataclass(frozen=True) +class OpenEOConnectionSettings: + backend_url: str = DEFAULT_OPENEO_BACKEND_URL + auth_method: str = "client_credentials" + client_id: str = "" + client_secret: str = "" + provider_id: str = "" + username: str = "" + password: str = "" + allow_interactive_oidc: bool = False + + @classmethod + def from_env(cls) -> "OpenEOConnectionSettings": + return cls( + backend_url=os.environ.get("OPENEO_BACKEND_URL", DEFAULT_OPENEO_BACKEND_URL).strip(), + auth_method=os.environ.get("OPENEO_AUTH_METHOD", "client_credentials").strip().lower(), + client_id=os.environ.get("OPENEO_AUTH_CLIENT_ID", "").strip(), + client_secret=os.environ.get("OPENEO_AUTH_CLIENT_SECRET", "").strip(), + provider_id=os.environ.get("OPENEO_AUTH_PROVIDER_ID", "").strip(), + username=os.environ.get("OPENEO_USERNAME", "").strip(), + password=os.environ.get("OPENEO_PASSWORD", "").strip(), + allow_interactive_oidc=os.environ.get("OPENEO_ALLOW_INTERACTIVE_OIDC", "0").strip().lower() + in {"1", "true", "yes", "on"}, + ) + + +def connect_openeo(settings: OpenEOConnectionSettings | None = None): + """ + Build an authenticated openEO connection using environment-driven configuration. + + Preferred authentication mode in production is OIDC client credentials. + """ + settings = settings or OpenEOConnectionSettings.from_env() + try: + import openeo + except ImportError as exc: # pragma: no cover - runtime dependency guard + raise OpenEOServiceError("The `openeo` Python client is required for remote sensing jobs.") from exc + + connection = openeo.connect(settings.backend_url) + try: + if settings.auth_method == "client_credentials": + if not settings.client_id or not settings.client_secret: + raise OpenEOAuthenticationError( + "OPENEO_AUTH_CLIENT_ID and OPENEO_AUTH_CLIENT_SECRET must be configured." + ) + auth_kwargs = { + "client_id": settings.client_id, + "client_secret": settings.client_secret, + } + if settings.provider_id: + auth_kwargs["provider_id"] = settings.provider_id + return connection.authenticate_oidc_client_credentials(**auth_kwargs) + + if settings.auth_method == "password": + if not settings.username or not settings.password: + raise OpenEOAuthenticationError( + "OPENEO_USERNAME and OPENEO_PASSWORD must be configured for password auth." + ) + auth_kwargs = { + "username": settings.username, + "password": settings.password, + } + if settings.provider_id: + auth_kwargs["provider_id"] = settings.provider_id + return connection.authenticate_oidc_resource_owner_password_credentials(**auth_kwargs) + + if settings.auth_method == "oidc": + if not settings.allow_interactive_oidc: + raise OpenEOAuthenticationError( + "Interactive OIDC auth is disabled. Use client credentials in Celery workers." + ) + auth_kwargs = {} + if settings.provider_id: + auth_kwargs["provider_id"] = settings.provider_id + return connection.authenticate_oidc(**auth_kwargs) + + raise OpenEOAuthenticationError(f"Unsupported OPENEO_AUTH_METHOD: {settings.auth_method}") + except Exception as exc: + if isinstance(exc, OpenEOServiceError): + raise + raise OpenEOAuthenticationError(f"Failed to authenticate with openEO backend: {exc}") from exc + + +def build_feature_collection(cells: list[AnalysisGridCell]) -> dict[str, Any]: + features = [] + for cell in cells: + features.append( + { + "type": "Feature", + "id": cell.cell_code, + "properties": { + "cell_code": cell.cell_code, + "block_code": cell.block_code, + "soil_location_id": cell.soil_location_id, + }, + "geometry": cell.geometry, + } + ) + return {"type": "FeatureCollection", "features": features} + + +def build_spatial_extent(cells: list[AnalysisGridCell]) -> dict[str, float]: + if not cells: + raise ValueError("At least one analysis grid cell is required.") + + west = None + east = None + south = None + north = None + for cell in cells: + coordinates = ((cell.geometry or {}).get("coordinates") or [[]])[0] + for lon, lat in coordinates: + west = lon if west is None else min(west, lon) + east = lon if east is None else max(east, lon) + south = lat if south is None else min(south, lat) + north = lat if north is None else max(north, lat) + + return { + "west": float(west), + "south": float(south), + "east": float(east), + "north": float(north), + } + + +def build_empty_metric_payload() -> dict[str, Any]: + return {metric_name: None for metric_name in METRIC_NAMES} + + +def initialize_metric_result_map(cells: list[AnalysisGridCell]) -> dict[str, dict[str, Any]]: + return {cell.cell_code: build_empty_metric_payload() for cell in cells} + + +def compute_remote_sensing_metrics( + cells: list[AnalysisGridCell], + *, + temporal_start: date | str, + temporal_end: date | str, + connection=None, +) -> dict[str, Any]: + """ + Compute all requested remote sensing metrics in batch mode per metric. + + Returns a normalized structure keyed by `cell_code`, plus execution metadata + that can be stored by Celery tasks and Django models. + """ + if not cells: + return { + "results": {}, + "metadata": { + "backend": DEFAULT_OPENEO_PROVIDER, + "collections_used": [], + "slope_supported": False, + "job_refs": {}, + "failed_metrics": [], + }, + } + + connection = connection or connect_openeo() + feature_collection = build_feature_collection(cells) + spatial_extent = build_spatial_extent(cells) + results = initialize_metric_result_map(cells) + metadata = { + "backend": DEFAULT_OPENEO_PROVIDER, + "backend_url": DEFAULT_OPENEO_BACKEND_URL, + "collections_used": [ + SENTINEL2_COLLECTION, + SENTINEL3_LST_COLLECTION, + SENTINEL1_COLLECTION, + COPERNICUS_DEM_COLLECTION, + ], + "slope_supported": True, + "job_refs": {}, + "failed_metrics": [], + } + + metric_runners = [ + ("ndvi", compute_ndvi), + ("ndwi", compute_ndwi), + ("lst_c", compute_lst_c), + ("soil_vv", compute_soil_vv), + ("dem_m", compute_dem_m), + ("slope_deg", compute_slope_deg), + ] + for metric_name, runner in metric_runners: + try: + metric_payload = runner( + connection=connection, + feature_collection=feature_collection, + spatial_extent=spatial_extent, + temporal_start=temporal_start, + temporal_end=temporal_end, + ) + merge_metric_results(results, metric_payload["results"]) + metadata["job_refs"][metric_name] = metric_payload.get("job_ref") + if metric_name == "slope_deg" and not metric_payload.get("supported", True): + metadata["slope_supported"] = False + except Exception as exc: + if metric_name == "slope_deg": + metadata["slope_supported"] = False + metadata["failed_metrics"].append( + {"metric": metric_name, "error": str(exc), "non_fatal": True} + ) + continue + raise OpenEOExecutionError(f"Failed to compute metric `{metric_name}`: {exc}") from exc + + for cell_code, payload in results.items(): + soil_vv = payload.get("soil_vv") + payload["soil_vv_db"] = linear_to_db(soil_vv) + + return {"results": results, "metadata": metadata} + + +def compute_ndvi(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + SENTINEL2_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + bands=["B03", "B04", "B08", "SCL"], + ) + scl = cube.band("SCL") + invalid_mask = (scl != VALID_SCL_CLASSES[0]) & (scl != VALID_SCL_CLASSES[1]) & (scl != VALID_SCL_CLASSES[2]) + red = cube.band("B04") * 0.0001 + nir = cube.band("B08") * 0.0001 + ndvi = ((nir - red) / (nir + red)).mask(invalid_mask.resample_cube_spatial(red)) + aggregated = ndvi.mean_time().aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return {"results": parse_aggregate_spatial_response(aggregated, "ndvi")} + + +def compute_ndwi(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + SENTINEL2_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + bands=["B03", "B08", "SCL"], + ) + scl = cube.band("SCL") + invalid_mask = (scl != VALID_SCL_CLASSES[0]) & (scl != VALID_SCL_CLASSES[1]) & (scl != VALID_SCL_CLASSES[2]) + green = cube.band("B03") * 0.0001 + nir = cube.band("B08") * 0.0001 + ndwi = ((green - nir) / (green + nir)).mask(invalid_mask.resample_cube_spatial(green)) + aggregated = ndwi.mean_time().aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return {"results": parse_aggregate_spatial_response(aggregated, "ndwi")} + + +def compute_lst_c(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + SENTINEL3_LST_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + ) + band_name = infer_band_name(cube, preferred=("LST", "LST_in", "LST", "band_0")) + lst_k = cube.band(band_name) if band_name else cube + lst_c = lst_k - 273.15 + aggregated = lst_c.mean_time().aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return {"results": parse_aggregate_spatial_response(aggregated, "lst_c")} + + +def compute_soil_vv(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + SENTINEL1_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + bands=["VV"], + ) + vv = cube.band("VV") + aggregated = vv.mean_time().aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return {"results": parse_aggregate_spatial_response(aggregated, "soil_vv")} + + +def compute_dem_m(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + COPERNICUS_DEM_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + ) + band_name = infer_band_name(cube, preferred=("DEM", "elevation", "band_0")) + dem = cube.band(band_name) if band_name else cube + aggregated = dem.aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return {"results": parse_aggregate_spatial_response(aggregated, "dem_m")} + + +def compute_slope_deg(*, connection, feature_collection, spatial_extent, temporal_start, temporal_end) -> dict[str, Any]: + cube = connection.load_collection( + COPERNICUS_DEM_COLLECTION, + spatial_extent=spatial_extent, + temporal_extent=[_normalize_date(temporal_start), _normalize_date(temporal_end)], + ) + band_name = infer_band_name(cube, preferred=("DEM", "elevation", "band_0")) + dem = cube.band(band_name) if band_name else cube + try: + slope_rad = dem.slope() + slope_deg = slope_rad * (180.0 / math.pi) + aggregated = slope_deg.aggregate_spatial(geometries=feature_collection, reducer="mean").execute() + return { + "results": parse_aggregate_spatial_response(aggregated, "slope_deg"), + "supported": True, + } + except Exception: + return { + "results": {feature["id"]: {"slope_deg": None} for feature in feature_collection.get("features", [])}, + "supported": False, + } + + +def parse_aggregate_spatial_response(payload: Any, metric_name: str) -> dict[str, dict[str, Any]]: + """ + Parse different JSON shapes returned by openEO aggregate_spatial executions. + """ + if payload is None: + return {} + + if isinstance(payload, dict) and payload.get("type") == "FeatureCollection": + return _parse_feature_collection_results(payload, metric_name) + + if isinstance(payload, dict) and "features" in payload: + return _parse_feature_collection_results(payload, metric_name) + + if isinstance(payload, dict): + return _parse_mapping_results(payload, metric_name) + + if isinstance(payload, list): + return _parse_list_results(payload, metric_name) + + raise OpenEOExecutionError(f"Unsupported openEO aggregate_spatial response type: {type(payload)!r}") + + +def _parse_feature_collection_results(payload: dict[str, Any], metric_name: str) -> dict[str, dict[str, Any]]: + results: dict[str, dict[str, Any]] = {} + for feature in payload.get("features", []): + feature_id = str( + feature.get("id") + or (feature.get("properties") or {}).get("cell_code") + or (feature.get("properties") or {}).get("id") + ) + if not feature_id: + continue + properties = feature.get("properties") or {} + value = _extract_aggregate_value(properties) + results[feature_id] = {metric_name: _coerce_float(value)} + return results + + +def _parse_mapping_results(payload: dict[str, Any], metric_name: str) -> dict[str, dict[str, Any]]: + if "data" in payload and isinstance(payload["data"], (dict, list)): + return parse_aggregate_spatial_response(payload["data"], metric_name) + + results: dict[str, dict[str, Any]] = {} + for feature_id, value in payload.items(): + if feature_id in {"type", "links", "meta"}: + continue + results[str(feature_id)] = {metric_name: _coerce_float(_extract_aggregate_value(value))} + return results + + +def _parse_list_results(payload: list[Any], metric_name: str) -> dict[str, dict[str, Any]]: + results: dict[str, dict[str, Any]] = {} + for index, item in enumerate(payload): + if isinstance(item, dict): + feature_id = str(item.get("id") or item.get("cell_code") or item.get("feature_id") or index) + value = _extract_aggregate_value(item) + else: + feature_id = str(index) + value = item + results[feature_id] = {metric_name: _coerce_float(value)} + return results + + +def _extract_aggregate_value(value: Any) -> Any: + if isinstance(value, dict): + for key in ("mean", "value", "result", "average"): + if key in value: + return _extract_aggregate_value(value[key]) + if len(value) == 1: + return _extract_aggregate_value(next(iter(value.values()))) + return None + if isinstance(value, list): + if not value: + return None + return _extract_aggregate_value(value[0]) + return value + + +def merge_metric_results(target: dict[str, dict[str, Any]], updates: dict[str, dict[str, Any]]) -> None: + for cell_code, values in updates.items(): + target.setdefault(cell_code, build_empty_metric_payload()) + target[cell_code].update(values) + + +def linear_to_db(value: Any) -> float | None: + numeric = _coerce_float(value) + if numeric is None or numeric <= 0: + return None + return round(10.0 * math.log10(numeric), 6) + + +def infer_band_name(cube, preferred: tuple[str, ...]) -> str | None: + """ + Best-effort band name selection for collections with backend-specific naming. + """ + metadata = getattr(cube, "metadata", None) + if metadata is None: + return None + band_dimension = getattr(metadata, "band_dimension", None) + bands = getattr(band_dimension, "bands", None) + if not bands: + return None + available = [] + for band in bands: + name = getattr(band, "name", None) or str(band) + available.append(name) + for candidate in preferred: + if candidate in available: + return candidate + return available[0] if available else None + + +def _coerce_float(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _normalize_date(value: date | str) -> str: + if isinstance(value, date): + return value.isoformat() + return str(value) diff --git a/Modules/Ai/location_data/postman/soil_data.json b/Modules/Ai/location_data/postman/soil_data.json new file mode 100644 index 0000000..0c1d060 --- /dev/null +++ b/Modules/Ai/location_data/postman/soil_data.json @@ -0,0 +1,93 @@ +{ + "info": { + "name": "Soil Data", + "description": "API داده‌های خاک (SoilGrids) بر اساس مختصات جغرافیایی", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8020" + }, + { + "key": "task_id", + "value": "" + } + ], + "item": [ + { + "name": "Get Soil Data (query)", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/api/soil-data/?lon=52.42&lat=36.38", + "host": ["{{baseUrl}}"], + "path": ["api", "soil-data", ""], + "query": [ + { + "key": "lon", + "value": "52.42", + "description": "طول جغرافیایی" + }, + { + "key": "lat", + "value": "36.38", + "description": "عرض جغرافیایی" + } + ] + }, + "description": "دریافت داده خاک با lon و lat در query. اگر داده در DB باشد 200، وگرنه 202 با task_id برمی‌گردد." + } + }, + { + "name": "Get Soil Data (POST)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"lon\": 52.42,\n \"lat\": 36.38\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/soil-data/", + "host": ["{{baseUrl}}"], + "path": ["api", "soil-data", ""] + }, + "description": "دریافت داده خاک با lon و lat در body. اگر داده در DB باشد 200، وگرنه 202 با task_id برمی‌گردد." + } + }, + { + "name": "Task Status", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "url": { + "raw": "{{baseUrl}}/api/soil-data/tasks/{{task_id}}/status/", + "host": ["{{baseUrl}}"], + "path": ["api", "soil-data", "tasks", "{{task_id}}", "status", ""] + }, + "description": "بررسی وضعیت تسک واکشی خاک. task_id را از پاسخ 202 دریافت می‌کنید." + } + } + ] +} diff --git a/Modules/Ai/location_data/remote_sensing.py b/Modules/Ai/location_data/remote_sensing.py new file mode 100644 index 0000000..422f73a --- /dev/null +++ b/Modules/Ai/location_data/remote_sensing.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + +import requests + +from .models import NdviObservation + + +DEFAULT_SATELLITE_SOURCE = "sentinel-2" +DEFAULT_CLOUD_COVER = 20.0 + + +def classify_ndvi(mean_ndvi: float) -> str: + if mean_ndvi < 0.2: + return "Bare soil" + if mean_ndvi < 0.4: + return "Weak vegetation" + if mean_ndvi < 0.6: + return "Moderate vegetation" + return "Healthy vegetation" + + +def calculate_ndvi(red: float, nir: float) -> float | None: + denominator = nir + red + if denominator == 0: + return None + return round((nir - red) / denominator, 4) + + +def calculate_ndvi_grid(red_band: list[list[float]], nir_band: list[list[float]]) -> list[list[float | None]]: + grid: list[list[float | None]] = [] + for red_row, nir_row in zip(red_band, nir_band): + row: list[float | None] = [] + for red, nir in zip(red_row, nir_row): + row.append(calculate_ndvi(float(red), float(nir))) + grid.append(row) + return grid + + +def mean_ndvi(grid: list[list[float | None]]) -> float: + values = [value for row in grid for value in row if value is not None] + if not values: + return 0.0 + return round(sum(values) / len(values), 4) + + +def _default_bbox(location: Any, delta: float = 0.001) -> list[float]: + lat = float(location.latitude) + lon = float(location.longitude) + return [lon - delta, lat - delta, lon + delta, lat + delta] + + +def _geometry_payload(location: Any) -> dict: + boundary = getattr(location, "farm_boundary", None) or {} + if boundary: + return boundary + return {"bbox": _default_bbox(location)} + + +@dataclass +class SatelliteNdviResult: + observation_date: str + mean_ndvi: float + ndvi_map: list[list[float | None]] + vegetation_health_class: str + satellite_source: str + cloud_cover: float | None + metadata: dict[str, Any] + + +class SentinelCompatibleNdviClient: + def __init__(self) -> None: + self.endpoint = os.environ.get("SATELLITE_NDVI_ENDPOINT") + self.api_key = os.environ.get("SATELLITE_NDVI_API_KEY") + self.source = os.environ.get("SATELLITE_SOURCE", DEFAULT_SATELLITE_SOURCE) + + @property + def is_configured(self) -> bool: + return bool(self.endpoint and self.api_key) + + def fetch_red_nir( + self, + geometry: dict, + date_from: date, + date_to: date, + cloud_cover: float, + ) -> dict[str, Any] | None: + if not self.is_configured: + return None + + response = requests.post( + self.endpoint, + json={ + "geometry": geometry, + "date_from": date_from.isoformat(), + "date_to": date_to.isoformat(), + "cloud_cover_max": cloud_cover, + "source": self.source, + "bands": ["B04", "B08"], + }, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def fetch_or_get_ndvi_observation( + location: Any, + days_back: int = 7, + cloud_cover: float = DEFAULT_CLOUD_COVER, +) -> NdviObservation | None: + observation = location.ndvi_observations.order_by("-observation_date", "-created_at").first() + if observation is not None: + return observation + + client = SentinelCompatibleNdviClient() + payload = client.fetch_red_nir( + geometry=_geometry_payload(location), + date_from=date.today() - timedelta(days=days_back), + date_to=date.today(), + cloud_cover=cloud_cover, + ) + if not payload: + return None + + red_band = payload.get("red_band") or [] + nir_band = payload.get("nir_band") or [] + observation_date = payload.get("observation_date") or date.today().isoformat() + ndvi_grid = calculate_ndvi_grid(red_band=red_band, nir_band=nir_band) + ndvi_mean = mean_ndvi(ndvi_grid) + return NdviObservation.objects.create( + location=location, + observation_date=date.fromisoformat(observation_date), + mean_ndvi=ndvi_mean, + ndvi_map={ + "grid": ndvi_grid, + "red_band_source": "B04", + "nir_band_source": "B08", + }, + vegetation_health_class=classify_ndvi(ndvi_mean), + satellite_source=payload.get("satellite_source", client.source), + cloud_cover=payload.get("cloud_cover"), + metadata={ + "geometry": _geometry_payload(location), + "raw_payload_meta": payload.get("metadata", {}), + }, + ) diff --git a/Modules/Ai/location_data/satellite_snapshot.py b/Modules/Ai/location_data/satellite_snapshot.py new file mode 100644 index 0000000..3de41c5 --- /dev/null +++ b/Modules/Ai/location_data/satellite_snapshot.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import Any + +from django.db.models import Avg, QuerySet + +from .models import AnalysisGridObservation, RemoteSensingRun, SoilLocation + + +SATELLITE_METRIC_FIELDS = ( + "ndvi", + "ndwi", + "lst_c", + "soil_vv_db", + "dem_m", + "slope_deg", +) + + +def build_location_satellite_snapshot( + location: SoilLocation, + *, + block_code: str = "", +) -> dict[str, Any]: + run = get_latest_completed_remote_sensing_run(location, block_code=block_code) + if run is None: + return { + "status": "missing", + "block_code": block_code, + "run_id": None, + "temporal_extent": None, + "cell_count": 0, + "resolved_metrics": {}, + "metric_sources": {}, + } + + observations = get_run_observations(run) + summary = summarize_observations(observations) + return { + "status": "completed", + "block_code": run.block_code, + "run_id": run.id, + "temporal_extent": { + "start_date": run.temporal_start.isoformat() if run.temporal_start else None, + "end_date": run.temporal_end.isoformat() if run.temporal_end else None, + }, + "cell_count": observations.count(), + "resolved_metrics": summary, + "metric_sources": { + metric_name: "remote_sensing" + for metric_name in summary + }, + } + + +def build_location_block_satellite_snapshots(location: SoilLocation) -> list[dict[str, Any]]: + block_layout = location.block_layout or {} + blocks = block_layout.get("blocks") or [] + if not blocks: + return [build_location_satellite_snapshot(location)] + snapshots = [] + for block in blocks: + snapshots.append( + build_location_satellite_snapshot( + location, + block_code=str(block.get("block_code") or "").strip(), + ) + ) + return snapshots + + +def get_latest_completed_remote_sensing_run( + location: SoilLocation, + *, + block_code: str = "", +) -> RemoteSensingRun | None: + return ( + RemoteSensingRun.objects.filter( + soil_location=location, + block_code=block_code or "", + status=RemoteSensingRun.STATUS_SUCCESS, + ) + .order_by("-temporal_end", "-created_at", "-id") + .first() + ) + + +def get_run_observations(run: RemoteSensingRun) -> QuerySet[AnalysisGridObservation]: + return ( + AnalysisGridObservation.objects.select_related("cell", "run") + .filter( + cell__soil_location=run.soil_location, + cell__block_code=run.block_code or "", + temporal_start=run.temporal_start, + temporal_end=run.temporal_end, + ) + .order_by("cell__cell_code") + ) + + +def summarize_observations( + observations: QuerySet[AnalysisGridObservation], +) -> dict[str, float]: + aggregates = observations.aggregate( + **{ + f"{metric_name}_mean": Avg(metric_name) + for metric_name in SATELLITE_METRIC_FIELDS + } + ) + summary: dict[str, float] = {} + for metric_name in SATELLITE_METRIC_FIELDS: + value = aggregates.get(f"{metric_name}_mean") + if value is None: + continue + summary[metric_name] = round(float(value), 6) + return summary diff --git a/Modules/Ai/location_data/serializers.py b/Modules/Ai/location_data/serializers.py new file mode 100644 index 0000000..4cdae21 --- /dev/null +++ b/Modules/Ai/location_data/serializers.py @@ -0,0 +1,340 @@ +from rest_framework import serializers + +from .data_driven_subdivision import SUPPORTED_CLUSTER_FEATURES +from .models import ( + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingRun, + RemoteSensingClusterAssignment, + RemoteSensingSubdivisionResult, + SoilLocation, +) +from .satellite_snapshot import build_location_block_satellite_snapshots + + +class SoilDataRequestSerializer(serializers.Serializer): + """ورودی ثبت مزرعه و بلوک‌های تعریف‌شده توسط کشاورز.""" + + class BlockInputSerializer(serializers.Serializer): + block_code = serializers.CharField(max_length=64) + boundary = serializers.JSONField() + order = serializers.IntegerField(required=False, min_value=1) + + lon = serializers.DecimalField(max_digits=9, decimal_places=6, required=True) + lat = serializers.DecimalField(max_digits=9, decimal_places=6, required=True) + block_count = serializers.IntegerField(required=False, min_value=1, default=1) + block_code = serializers.CharField(required=False, default="block-1", max_length=64) + farm_boundary = serializers.JSONField(required=False) + blocks = BlockInputSerializer(many=True, required=False) + + def validate(self, attrs): + blocks = attrs.get("blocks") or [] + if self.context.get("require_farm_boundary") and not attrs.get("farm_boundary"): + raise serializers.ValidationError( + {"farm_boundary": ["مختصات گوشه‌های کل زمین باید ارسال شود."]} + ) + if self.context.get("require_farm_boundary") and not blocks: + raise serializers.ValidationError( + {"blocks": ["مختصات بلوک‌های تعریف‌شده توسط کشاورز باید ارسال شود."]} + ) + if blocks: + attrs["block_count"] = len(blocks) + return attrs + + +class SoilLocationResponseSerializer(serializers.ModelSerializer): + """سریالایزر خروجی برای SoilLocation همراه با خلاصه سنجش‌ازدور.""" + + lon = serializers.DecimalField( + source="longitude", + max_digits=9, + decimal_places=6, + read_only=True, + ) + lat = serializers.DecimalField( + source="latitude", + max_digits=9, + decimal_places=6, + read_only=True, + ) + input_block_count = serializers.IntegerField(read_only=True) + farm_boundary = serializers.JSONField(read_only=True) + block_layout = serializers.JSONField(read_only=True) + block_subdivisions = serializers.SerializerMethodField() + satellite_snapshots = serializers.SerializerMethodField() + + class Meta: + model = SoilLocation + fields = [ + "id", + "lon", + "lat", + "input_block_count", + "farm_boundary", + "block_layout", + "block_subdivisions", + "satellite_snapshots", + ] + + def get_block_subdivisions(self, obj): + subdivisions = obj.block_subdivisions.all().order_by("block_code", "id") + return BlockSubdivisionSerializer(subdivisions, many=True).data + + def get_satellite_snapshots(self, obj): + return build_location_block_satellite_snapshots(obj) + + +class BlockSubdivisionSerializer(serializers.ModelSerializer): + elbow_plot = serializers.ImageField(read_only=True) + + class Meta: + model = BlockSubdivision + fields = [ + "block_code", + "chunk_size_sqm", + "grid_points", + "centroid_points", + "grid_point_count", + "centroid_count", + "elbow_plot", + "status", + "metadata", + "created_at", + "updated_at", + ] + + +class SoilDataTaskResponseSerializer(serializers.Serializer): + """سریالایزر خروجی وقتی تسک در صف قرار گرفته (۲۰۲).""" + + source = serializers.CharField(default="task") + task_id = serializers.CharField() + lon = serializers.FloatField(source="longitude") + lat = serializers.FloatField(source="latitude") + status_url = serializers.CharField(required=False) + + +class NdviHealthRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class NdviHealthDataItemSerializer(serializers.Serializer): + title = serializers.CharField() + value = serializers.JSONField() + color = serializers.CharField() + icon = serializers.CharField() + + +class NdviHealthResponseSerializer(serializers.Serializer): + ndviIndex = serializers.FloatField(allow_null=True, required=False) + mean_ndvi = serializers.FloatField(allow_null=True) + ndvi_map = serializers.JSONField() + vegetation_health_class = serializers.CharField(allow_null=True) + observation_date = serializers.CharField(allow_null=True) + satellite_source = serializers.CharField(allow_null=True) + healthData = NdviHealthDataItemSerializer(many=True) + + +class RemoteSensingTriggerSerializer(serializers.Serializer): + lon = serializers.DecimalField(max_digits=9, decimal_places=6, required=True) + lat = serializers.DecimalField(max_digits=9, decimal_places=6, required=True) + block_code = serializers.CharField(required=False, allow_blank=True, default="", max_length=64) + start_date = serializers.DateField(required=True) + end_date = serializers.DateField(required=True) + force_refresh = serializers.BooleanField(required=False, default=False) + cluster_count = serializers.IntegerField(required=False, min_value=1, allow_null=True, default=None) + selected_features = serializers.ListField( + child=serializers.CharField(max_length=64), + required=False, + allow_empty=False, + ) + + def validate(self, attrs): + if attrs["start_date"] > attrs["end_date"]: + raise serializers.ValidationError("start_date نمی‌تواند بعد از end_date باشد.") + selected_features = attrs.get("selected_features") or [] + invalid_features = sorted( + feature_name + for feature_name in selected_features + if feature_name not in SUPPORTED_CLUSTER_FEATURES + ) + if invalid_features: + raise serializers.ValidationError( + { + "selected_features": [ + "ویژگی‌های نامعتبر برای خوشه‌بندی: " + + ", ".join(invalid_features) + ] + } + ) + return attrs + + +class RemoteSensingResultQuerySerializer(RemoteSensingTriggerSerializer): + page = serializers.IntegerField(required=False, min_value=1, default=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=200, default=100) + + +class RemoteSensingCellObservationSerializer(serializers.ModelSerializer): + cell_code = serializers.CharField(source="cell.cell_code", read_only=True) + block_code = serializers.CharField(source="cell.block_code", read_only=True) + chunk_size_sqm = serializers.IntegerField(source="cell.chunk_size_sqm", read_only=True) + centroid_lat = serializers.DecimalField(source="cell.centroid_lat", max_digits=9, decimal_places=6, read_only=True) + centroid_lon = serializers.DecimalField(source="cell.centroid_lon", max_digits=9, decimal_places=6, read_only=True) + geometry = serializers.JSONField(source="cell.geometry", read_only=True) + + class Meta: + model = AnalysisGridObservation + fields = [ + "cell_code", + "block_code", + "chunk_size_sqm", + "centroid_lat", + "centroid_lon", + "geometry", + "temporal_start", + "temporal_end", + "ndvi", + "ndwi", + "lst_c", + "soil_vv", + "soil_vv_db", + "dem_m", + "slope_deg", + "metadata", + ] + + +class RemoteSensingSummarySerializer(serializers.Serializer): + cell_count = serializers.IntegerField() + ndvi_mean = serializers.FloatField(allow_null=True) + ndwi_mean = serializers.FloatField(allow_null=True) + lst_c_mean = serializers.FloatField(allow_null=True) + soil_vv_db_mean = serializers.FloatField(allow_null=True) + dem_m_mean = serializers.FloatField(allow_null=True) + slope_deg_mean = serializers.FloatField(allow_null=True) + + +class RemoteSensingRunSerializer(serializers.ModelSerializer): + status_label = serializers.SerializerMethodField() + pipeline_status = serializers.SerializerMethodField() + stage = serializers.SerializerMethodField() + selected_features = serializers.SerializerMethodField() + requested_cluster_count = serializers.SerializerMethodField() + + def get_status_label(self, obj): + return obj.normalized_status + + def get_pipeline_status(self, obj): + return obj.normalized_status + + def get_stage(self, obj): + return (obj.metadata or {}).get("stage") + + def get_selected_features(self, obj): + return (obj.metadata or {}).get("selected_features", []) + + def get_requested_cluster_count(self, obj): + return (obj.metadata or {}).get("requested_cluster_count") + + class Meta: + model = RemoteSensingRun + fields = [ + "id", + "block_code", + "chunk_size_sqm", + "temporal_start", + "temporal_end", + "status", + "status_label", + "pipeline_status", + "stage", + "selected_features", + "requested_cluster_count", + "metadata", + "error_message", + "started_at", + "finished_at", + "created_at", + "updated_at", + ] + + +class RemoteSensingClusterAssignmentSerializer(serializers.ModelSerializer): + cell_code = serializers.CharField(source="cell.cell_code", read_only=True) + centroid_lat = serializers.DecimalField(source="cell.centroid_lat", max_digits=9, decimal_places=6, read_only=True) + centroid_lon = serializers.DecimalField(source="cell.centroid_lon", max_digits=9, decimal_places=6, read_only=True) + + class Meta: + model = RemoteSensingClusterAssignment + fields = [ + "cell_code", + "cluster_label", + "centroid_lat", + "centroid_lon", + "raw_feature_values", + "scaled_feature_values", + ] + + +class RemoteSensingSubdivisionResultSerializer(serializers.ModelSerializer): + assignments = serializers.SerializerMethodField() + + def get_assignments(self, obj): + assignments = self.context.get("paginated_assignments") + if assignments is None: + assignments = obj.assignments.all().order_by("cluster_label", "cell__cell_code") + return RemoteSensingClusterAssignmentSerializer(assignments, many=True).data + + class Meta: + model = RemoteSensingSubdivisionResult + fields = [ + "id", + "block_code", + "chunk_size_sqm", + "temporal_start", + "temporal_end", + "cluster_count", + "selected_features", + "skipped_cell_codes", + "metadata", + "assignments", + "created_at", + "updated_at", + ] + + +class RemoteSensingResponseSerializer(serializers.Serializer): + status = serializers.CharField() + source = serializers.CharField() + location = SoilLocationResponseSerializer() + block_code = serializers.CharField(allow_blank=True) + chunk_size_sqm = serializers.IntegerField(allow_null=True) + temporal_extent = serializers.JSONField() + summary = RemoteSensingSummarySerializer() + cells = RemoteSensingCellObservationSerializer(many=True) + run = RemoteSensingRunSerializer(allow_null=True) + subdivision_result = RemoteSensingSubdivisionResultSerializer(allow_null=True) + pagination = serializers.JSONField(required=False) + + + +class RemoteSensingRunStatusResponseSerializer(serializers.Serializer): + status = serializers.CharField() + source = serializers.CharField() + run = RemoteSensingRunSerializer() + task_id = serializers.CharField(allow_blank=True, allow_null=True, required=False) + + +class RemoteSensingRunResultResponseSerializer(serializers.Serializer): + status = serializers.CharField() + source = serializers.CharField() + location = SoilLocationResponseSerializer() + block_code = serializers.CharField(allow_blank=True) + chunk_size_sqm = serializers.IntegerField(allow_null=True) + temporal_extent = serializers.JSONField() + summary = RemoteSensingSummarySerializer() + cells = RemoteSensingCellObservationSerializer(many=True) + run = RemoteSensingRunSerializer() + subdivision_result = RemoteSensingSubdivisionResultSerializer(allow_null=True) + pagination = serializers.JSONField(required=False) diff --git a/Modules/Ai/location_data/tasks.py b/Modules/Ai/location_data/tasks.py new file mode 100644 index 0000000..355a494 --- /dev/null +++ b/Modules/Ai/location_data/tasks.py @@ -0,0 +1,615 @@ +""" +تسک‌های Celery برای pipeline سنجش‌ازدور و subdivision داده‌محور. +""" + +import logging +from typing import Any + +from config.celery import app +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django.utils.dateparse import parse_date + +from .data_driven_subdivision import ( + DEFAULT_CLUSTER_FEATURES, + DataDrivenSubdivisionError, + create_remote_sensing_subdivision_result, +) +from .grid_analysis import create_or_get_analysis_grid_cells +from .models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) +from .openeo_service import ( + OpenEOAuthenticationError, + OpenEOExecutionError, + OpenEOServiceError, + compute_remote_sensing_metrics, +) + +try: + import requests +except ImportError: # pragma: no cover - handled in stripped envs + RequestException = Exception +else: + RequestException = requests.RequestException + + +logger = logging.getLogger(__name__) + + +def run_remote_sensing_analysis( + *, + soil_location_id: int, + block_code: str = "", + temporal_start: Any, + temporal_end: Any, + force_refresh: bool = False, + task_id: str = "", + run_id: int | None = None, + cluster_count: int | None = None, + selected_features: list[str] | None = None, +) -> dict[str, Any]: + """ + اجرای سنکرون تحلیل سنجش‌ازدور برای یک location/block. + این helper برای Celery task و هر orchestration داخلی دیگر قابل استفاده است. + """ + start_date = _normalize_temporal_date(temporal_start, "temporal_start") + end_date = _normalize_temporal_date(temporal_end, "temporal_end") + if start_date > end_date: + raise ValueError("temporal_start نمی‌تواند بعد از temporal_end باشد.") + + location = SoilLocation.objects.filter(pk=soil_location_id).first() + if location is None: + raise ValueError(f"SoilLocation با id={soil_location_id} پیدا نشد.") + + resolved_block_code = str(block_code or "").strip() + subdivision = _resolve_block_subdivision(location, resolved_block_code) + run = _get_or_create_remote_sensing_run( + run_id=run_id, + location=location, + subdivision=subdivision, + block_code=resolved_block_code, + temporal_start=start_date, + temporal_end=end_date, + task_id=task_id, + cluster_count=cluster_count, + selected_features=selected_features or list(DEFAULT_CLUSTER_FEATURES), + ) + _mark_run_running(run) + + try: + _record_run_stage( + run, + "preparing_analysis_grid", + { + "block_code": resolved_block_code, + "temporal_extent": { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + }, + }, + ) + grid_summary = create_or_get_analysis_grid_cells( + location, + block_code=resolved_block_code, + block_subdivision=subdivision, + ) + _record_run_stage(run, "analysis_grid_ready", {"grid_summary": grid_summary}) + all_cells = _load_grid_cells(location, resolved_block_code) + cells_to_process = _select_cells_for_processing( + all_cells=all_cells, + temporal_start=start_date, + temporal_end=end_date, + force_refresh=force_refresh, + ) + _record_run_stage( + run, + "analysis_cells_selected", + { + "cell_selection": { + "total_cell_count": len(all_cells), + "cell_count_to_process": len(cells_to_process), + "existing_cell_count": len(all_cells) - len(cells_to_process), + "force_refresh": force_refresh, + } + }, + ) + + if not cells_to_process: + _record_run_stage( + run, + "using_cached_observations", + {"source": "database"}, + ) + observations = _load_observations( + location=location, + block_code=resolved_block_code, + temporal_start=start_date, + temporal_end=end_date, + ) + subdivision_result = _ensure_subdivision_result( + location=location, + run=run, + subdivision=subdivision, + block_code=resolved_block_code, + observations=observations, + cluster_count=cluster_count, + selected_features=selected_features, + ) + _record_run_stage( + run, + "clustering_completed", + _build_clustering_stage_metadata(subdivision_result), + ) + summary = { + "status": "completed", + "source": "database", + "run_id": run.id, + "processed_cell_count": 0, + "created_observation_count": 0, + "updated_observation_count": 0, + "existing_observation_count": len(all_cells), + "failed_metric_count": 0, + "chunk_size_sqm": grid_summary["chunk_size_sqm"], + "block_code": resolved_block_code, + "cell_count": len(all_cells), + "subdivision_result_id": getattr(subdivision_result, "id", None), + "cluster_count": getattr(subdivision_result, "cluster_count", 0), + } + _mark_run_success(run, summary) + return summary + + _record_run_stage( + run, + "fetching_remote_metrics", + {"requested_cell_count": len(cells_to_process)}, + ) + remote_payload = compute_remote_sensing_metrics( + cells_to_process, + temporal_start=start_date, + temporal_end=end_date, + ) + _record_run_stage( + run, + "remote_metrics_fetched", + { + "failed_metric_count": len(remote_payload["metadata"].get("failed_metrics", [])), + "service_metadata": remote_payload["metadata"], + }, + ) + upsert_summary = _upsert_grid_observations( + cells=cells_to_process, + run=run, + temporal_start=start_date, + temporal_end=end_date, + metric_payload=remote_payload, + ) + _record_run_stage(run, "observations_persisted", upsert_summary) + observations = _load_observations( + location=location, + block_code=resolved_block_code, + temporal_start=start_date, + temporal_end=end_date, + ) + subdivision_result = _ensure_subdivision_result( + location=location, + run=run, + subdivision=subdivision, + block_code=resolved_block_code, + observations=observations, + cluster_count=cluster_count, + selected_features=selected_features, + ) + _record_run_stage( + run, + "clustering_completed", + _build_clustering_stage_metadata(subdivision_result), + ) + summary = { + "status": "completed", + "source": "openeo", + "run_id": run.id, + "processed_cell_count": len(cells_to_process), + "created_observation_count": upsert_summary["created_count"], + "updated_observation_count": upsert_summary["updated_count"], + "existing_observation_count": len(all_cells) - len(cells_to_process), + "failed_metric_count": len(remote_payload["metadata"].get("failed_metrics", [])), + "chunk_size_sqm": grid_summary["chunk_size_sqm"], + "block_code": resolved_block_code, + "cell_count": len(all_cells), + "subdivision_result_id": subdivision_result.id, + "cluster_count": subdivision_result.cluster_count, + } + _mark_run_success(run, summary, remote_payload["metadata"]) + logger.info( + "Remote sensing analysis completed", + extra={ + "run_id": run.id, + "soil_location_id": location.id, + "block_code": resolved_block_code, + "processed_cell_count": summary["processed_cell_count"], + }, + ) + return summary + except Exception as exc: + _mark_run_failure(run, str(exc)) + raise + + +@app.task(bind=True, max_retries=3, default_retry_delay=60) +def run_remote_sensing_analysis_task( + self, + soil_location_id: int, + block_code: str = "", + temporal_start: Any = "", + temporal_end: Any = "", + force_refresh: bool = False, + run_id: int | None = None, + cluster_count: int | None = None, + selected_features: list[str] | None = None, +): + """ + اجرای async تحلیل سنجش‌ازدور برای location/block و ذخیره نتایج در DB. + """ + logger.info( + "Starting remote sensing analysis task", + extra={ + "task_id": self.request.id, + "soil_location_id": soil_location_id, + "block_code": block_code, + "temporal_start": temporal_start, + "temporal_end": temporal_end, + "force_refresh": force_refresh, + }, + ) + try: + return run_remote_sensing_analysis( + soil_location_id=soil_location_id, + block_code=block_code, + temporal_start=temporal_start, + temporal_end=temporal_end, + force_refresh=force_refresh, + task_id=self.request.id, + run_id=run_id, + cluster_count=cluster_count, + selected_features=selected_features, + ) + except OpenEOAuthenticationError: + logger.exception( + "Remote sensing auth failure", + extra={"task_id": self.request.id, "soil_location_id": soil_location_id}, + ) + raise + except (OpenEOExecutionError, OpenEOServiceError, RequestException, DataDrivenSubdivisionError) as exc: + logger.warning( + "Transient remote sensing failure, retrying task", + extra={ + "task_id": self.request.id, + "soil_location_id": soil_location_id, + "block_code": block_code, + "retry_count": self.request.retries, + "error": str(exc), + }, + ) + raise self.retry(exc=exc) + + +def _normalize_temporal_date(value: Any, field_name: str): + if hasattr(value, "isoformat") and not isinstance(value, str): + return value + parsed = parse_date(str(value)) + if parsed is None: + raise ValueError(f"{field_name} نامعتبر است.") + return parsed + + +def _resolve_block_subdivision(location: SoilLocation, block_code: str) -> BlockSubdivision | None: + if not block_code: + return None + return ( + BlockSubdivision.objects.filter( + soil_location=location, + block_code=block_code, + ) + .order_by("-updated_at", "-id") + .first() + ) + + +def _get_or_create_remote_sensing_run( + *, + run_id: int | None, + location: SoilLocation, + subdivision: BlockSubdivision | None, + block_code: str, + temporal_start, + temporal_end, + task_id: str, + cluster_count: int | None, + selected_features: list[str], +) -> RemoteSensingRun: + queued_at = timezone.now().isoformat() + if run_id is not None: + run = RemoteSensingRun.objects.filter(pk=run_id, soil_location=location).first() + if run is not None: + metadata = dict(run.metadata or {}) + if task_id: + metadata["task_id"] = task_id + metadata.setdefault("status_label", "pending") + metadata["stage"] = "queued" + metadata["selected_features"] = selected_features + metadata["requested_cluster_count"] = cluster_count + metadata["pipeline"] = { + "name": "remote_sensing_subdivision", + "version": 2, + } + metadata["timestamps"] = { + **dict(metadata.get("timestamps") or {}), + "queued_at": queued_at, + } + run.block_subdivision = subdivision + run.block_code = block_code + run.chunk_size_sqm = int(getattr(settings, "SUBDIVISION_CHUNK_SQM", 900) or 900) + run.temporal_start = temporal_start + run.temporal_end = temporal_end + run.metadata = metadata + run.save( + update_fields=[ + "block_subdivision", + "block_code", + "chunk_size_sqm", + "temporal_start", + "temporal_end", + "metadata", + "updated_at", + ] + ) + return run + metadata = { + "status_label": "pending", + "stage": "queued", + "selected_features": selected_features, + "requested_cluster_count": cluster_count, + "pipeline": { + "name": "remote_sensing_subdivision", + "version": 2, + }, + "timestamps": {"queued_at": queued_at}, + } + if task_id: + metadata["task_id"] = task_id + return RemoteSensingRun.objects.create( + soil_location=location, + block_subdivision=subdivision, + block_code=block_code, + chunk_size_sqm=int(getattr(settings, "SUBDIVISION_CHUNK_SQM", 900) or 900), + temporal_start=temporal_start, + temporal_end=temporal_end, + status=RemoteSensingRun.STATUS_PENDING, + metadata=metadata, + ) + + +def _mark_run_running(run: RemoteSensingRun) -> None: + metadata = dict(run.metadata or {}) + metadata["status_label"] = "running" + metadata["stage"] = "running" + metadata["timestamps"] = { + **dict(metadata.get("timestamps") or {}), + "started_at": timezone.now().isoformat(), + } + run.status = RemoteSensingRun.STATUS_RUNNING + run.started_at = timezone.now() + run.metadata = metadata + run.save(update_fields=["status", "started_at", "metadata", "updated_at"]) + + +def _mark_run_success( + run: RemoteSensingRun, + summary: dict[str, Any], + service_metadata: dict[str, Any] | None = None, +) -> None: + metadata = dict(run.metadata or {}) + metadata["summary"] = summary + metadata["status_label"] = "completed" + metadata["stage"] = "completed" + metadata["timestamps"] = { + **dict(metadata.get("timestamps") or {}), + "completed_at": timezone.now().isoformat(), + } + if service_metadata: + metadata["service"] = service_metadata + run.status = RemoteSensingRun.STATUS_SUCCESS + run.finished_at = timezone.now() + run.error_message = "" + run.metadata = metadata + run.save( + update_fields=[ + "status", + "finished_at", + "error_message", + "metadata", + "updated_at", + ] + ) + + +def _mark_run_failure(run: RemoteSensingRun, error_message: str) -> None: + metadata = dict(run.metadata or {}) + metadata["status_label"] = "failed" + metadata["failure_reason"] = error_message[:4000] + metadata["timestamps"] = { + **dict(metadata.get("timestamps") or {}), + "failed_at": timezone.now().isoformat(), + } + run.status = RemoteSensingRun.STATUS_FAILURE + run.finished_at = timezone.now() + run.error_message = error_message[:4000] + run.metadata = metadata + run.save( + update_fields=[ + "status", + "finished_at", + "error_message", + "metadata", + "updated_at", + ] + ) + logger.exception( + "Remote sensing analysis failed", + extra={"run_id": run.id, "soil_location_id": run.soil_location_id, "block_code": run.block_code}, + ) + + +def _load_grid_cells(location: SoilLocation, block_code: str) -> list[AnalysisGridCell]: + queryset = AnalysisGridCell.objects.filter(soil_location=location) + queryset = queryset.filter(block_code=block_code or "") + return list(queryset.order_by("cell_code")) + + +def _load_observations( + *, + location: SoilLocation, + block_code: str, + temporal_start, + temporal_end, +) -> list[AnalysisGridObservation]: + queryset = ( + AnalysisGridObservation.objects.select_related("cell", "run") + .filter( + cell__soil_location=location, + cell__block_code=block_code or "", + temporal_start=temporal_start, + temporal_end=temporal_end, + ) + .order_by("cell__cell_code") + ) + return list(queryset) + + +def _select_cells_for_processing( + *, + all_cells: list[AnalysisGridCell], + temporal_start, + temporal_end, + force_refresh: bool, +) -> list[AnalysisGridCell]: + if force_refresh: + return all_cells + + existing_ids = set( + AnalysisGridObservation.objects.filter( + cell__in=all_cells, + temporal_start=temporal_start, + temporal_end=temporal_end, + ).values_list("cell_id", flat=True) + ) + return [cell for cell in all_cells if cell.id not in existing_ids] + + +def _upsert_grid_observations( + *, + cells: list[AnalysisGridCell], + run: RemoteSensingRun, + temporal_start, + temporal_end, + metric_payload: dict[str, Any], +) -> dict[str, int]: + metadata_template = { + "backend_name": metric_payload["metadata"].get("backend"), + "backend_url": metric_payload["metadata"].get("backend_url"), + "collections_used": metric_payload["metadata"].get("collections_used", []), + "slope_supported": metric_payload["metadata"].get("slope_supported", False), + "job_refs": metric_payload["metadata"].get("job_refs", {}), + "failed_metrics": metric_payload["metadata"].get("failed_metrics", []), + "run_id": run.id, + } + result_by_cell = metric_payload.get("results", {}) + + created_count = 0 + updated_count = 0 + with transaction.atomic(): + for cell in cells: + values = result_by_cell.get(cell.cell_code, {}) + defaults = { + "run": run, + "ndvi": values.get("ndvi"), + "ndwi": values.get("ndwi"), + "lst_c": values.get("lst_c"), + "soil_vv": values.get("soil_vv"), + "soil_vv_db": values.get("soil_vv_db"), + "dem_m": values.get("dem_m"), + "slope_deg": values.get("slope_deg"), + "metadata": metadata_template, + } + observation, created = AnalysisGridObservation.objects.update_or_create( + cell=cell, + temporal_start=temporal_start, + temporal_end=temporal_end, + defaults=defaults, + ) + if created: + created_count += 1 + else: + updated_count += 1 + return {"created_count": created_count, "updated_count": updated_count} + + +def _ensure_subdivision_result( + *, + location: SoilLocation, + run: RemoteSensingRun, + subdivision: BlockSubdivision | None, + block_code: str, + observations: list[AnalysisGridObservation], + cluster_count: int | None, + selected_features: list[str] | None, +) -> RemoteSensingSubdivisionResult: + if not observations: + raise DataDrivenSubdivisionError("هیچ observation برای ساخت subdivision داده‌محور پیدا نشد.") + result = create_remote_sensing_subdivision_result( + location=location, + run=run, + observations=observations, + block_subdivision=subdivision, + block_code=block_code, + selected_features=selected_features or list(DEFAULT_CLUSTER_FEATURES), + explicit_k=cluster_count, + ) + return result + + +def _record_run_stage(run: RemoteSensingRun, stage: str, details: dict[str, Any] | None = None) -> None: + metadata = dict(run.metadata or {}) + metadata["stage"] = stage + metadata["stage_details"] = { + **dict(metadata.get("stage_details") or {}), + stage: details or {}, + } + metadata["timestamps"] = { + **dict(metadata.get("timestamps") or {}), + f"{stage}_at": timezone.now().isoformat(), + } + run.metadata = metadata + run.save(update_fields=["metadata", "updated_at"]) + + +def _build_clustering_stage_metadata( + result: RemoteSensingSubdivisionResult, +) -> dict[str, Any]: + metadata = dict(result.metadata or {}) + return { + "subdivision_result_id": result.id, + "cluster_count": result.cluster_count, + "selected_features": result.selected_features, + "used_cell_count": metadata.get("used_cell_count", 0), + "skipped_cell_count": metadata.get("skipped_cell_count", 0), + "skipped_cell_codes": result.skipped_cell_codes, + "kmeans_params": metadata.get("kmeans_params", {}), + } diff --git a/Modules/Ai/location_data/test_block_subdivision.py b/Modules/Ai/location_data/test_block_subdivision.py new file mode 100644 index 0000000..7900850 --- /dev/null +++ b/Modules/Ai/location_data/test_block_subdivision.py @@ -0,0 +1,44 @@ +from django.test import SimpleTestCase, override_settings + +from location_data.block_subdivision import ( + build_block_subdivision_payload, + detect_elbow_point, +) + + +@override_settings(SUBDIVISION_CHUNK_SQM=100) +class BlockSubdivisionServiceTests(SimpleTestCase): + def test_detect_elbow_point_from_sse_curve(self): + inertia_curve = [ + {"k": 1, "sse": 1000.0}, + {"k": 2, "sse": 400.0}, + {"k": 3, "sse": 220.0}, + {"k": 4, "sse": 180.0}, + ] + + optimal_k = detect_elbow_point(inertia_curve) + + self.assertEqual(optimal_k, 2) + + def test_build_block_subdivision_payload_returns_grid_and_centroids(self): + boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3902, 35.6890], + [51.3902, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + } + + result = build_block_subdivision_payload(boundary, block_code="block-1") + + self.assertEqual(result["block_code"], "block-1") + self.assertEqual(result["chunk_size_sqm"], 100) + self.assertGreater(result["grid_point_count"], 0) + self.assertGreater(result["centroid_count"], 0) + self.assertIn("optimal_k", result["metadata"]) + self.assertTrue(result["metadata"]["inertia_curve"]) diff --git a/Modules/Ai/location_data/test_grid_analysis.py b/Modules/Ai/location_data/test_grid_analysis.py new file mode 100644 index 0000000..881ecb6 --- /dev/null +++ b/Modules/Ai/location_data/test_grid_analysis.py @@ -0,0 +1,114 @@ +from django.test import TestCase, override_settings + +from location_data.grid_analysis import create_or_get_analysis_grid_cells +from location_data.models import AnalysisGridCell, BlockSubdivision, SoilLocation + + +@override_settings(SUBDIVISION_CHUNK_SQM=900) +class AnalysisGridServiceTests(TestCase): + def setUp(self): + self.boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.389000, 35.689000], + [51.389760, 35.689000], + [51.389760, 35.689620], + [51.389000, 35.689620], + [51.389000, 35.689000], + ] + ], + } + self.location = SoilLocation.objects.create( + latitude="35.689310", + longitude="51.389380", + farm_boundary=self.boundary, + ) + self.location.set_input_block_count(1) + self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"]) + self.subdivision = BlockSubdivision.objects.create( + soil_location=self.location, + block_code="block-1", + source_boundary=self.boundary, + chunk_size_sqm=900, + status="created", + ) + + def test_create_analysis_grid_cells_persists_30x30_cells(self): + result = create_or_get_analysis_grid_cells( + self.location, + block_code="block-1", + block_subdivision=self.subdivision, + ) + + self.assertTrue(result["created"]) + self.assertEqual(result["chunk_size_sqm"], 900) + self.assertGreater(result["created_count"], 0) + self.assertEqual(result["created_count"], result["total_count"]) + + cells = list( + AnalysisGridCell.objects.filter( + soil_location=self.location, + block_code="block-1", + chunk_size_sqm=900, + ).order_by("cell_code") + ) + self.assertEqual(len(cells), result["total_count"]) + self.assertTrue(all(cell.block_subdivision_id == self.subdivision.id for cell in cells)) + self.assertTrue(all(cell.geometry.get("type") == "Polygon" for cell in cells)) + self.assertTrue(all(len(cell.geometry.get("coordinates", [[]])[0]) == 5 for cell in cells)) + + self.subdivision.refresh_from_db() + self.location.refresh_from_db() + self.assertEqual( + self.subdivision.metadata["analysis_grid"]["chunk_size_sqm"], + 900, + ) + self.assertEqual( + self.subdivision.metadata["analysis_grid"]["cell_count"], + result["total_count"], + ) + self.assertEqual( + self.location.block_layout["blocks"][0]["analysis_grid_summary"]["chunk_size_sqm"], + 900, + ) + + def test_create_analysis_grid_cells_is_idempotent(self): + first = create_or_get_analysis_grid_cells( + self.location, + block_code="block-1", + block_subdivision=self.subdivision, + ) + second = create_or_get_analysis_grid_cells( + self.location, + block_code="block-1", + block_subdivision=self.subdivision, + ) + + self.assertTrue(first["created"]) + self.assertFalse(second["created"]) + self.assertEqual(second["created_count"], 0) + self.assertEqual(second["existing_count"], first["total_count"]) + self.assertEqual( + AnalysisGridCell.objects.filter( + soil_location=self.location, + block_code="block-1", + chunk_size_sqm=900, + ).count(), + first["total_count"], + ) + + def test_create_analysis_grid_cells_uses_location_boundary_without_subdivision(self): + result = create_or_get_analysis_grid_cells( + self.location, + block_code="", + ) + + self.assertGreater(result["total_count"], 0) + self.assertTrue( + AnalysisGridCell.objects.filter( + soil_location=self.location, + block_code="", + chunk_size_sqm=900, + ).exists() + ) diff --git a/Modules/Ai/location_data/test_ndvi_health_api.py b/Modules/Ai/location_data/test_ndvi_health_api.py new file mode 100644 index 0000000..cdd87e0 --- /dev/null +++ b/Modules/Ai/location_data/test_ndvi_health_api.py @@ -0,0 +1,66 @@ +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 + + +@override_settings(ROOT_URLCONF="location_data.urls") +class NdviHealthApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("location_data.views.apps.get_app_config") + def test_ndvi_health_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_ndvi_health=lambda **_kwargs: { + "ndviIndex": 0.68, + "mean_ndvi": 0.68, + "ndvi_map": {"grid": [[0.61, 0.7]]}, + "vegetation_health_class": "Healthy vegetation", + "observation_date": "2026-04-02", + "satellite_source": "sentinel-2", + "healthData": [ + { + "title": "سلامت پوشش گیاهی", + "value": "Healthy vegetation", + "color": "success", + "icon": "tabler-plant", + } + ], + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_ndvi_health_service=lambda: mock_service + ) + + response = self.client.post( + "/ndvi-health/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["mean_ndvi"], 0.68) + self.assertEqual(payload["vegetation_health_class"], "Healthy vegetation") + + @patch("location_data.views.apps.get_app_config") + def test_ndvi_health_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_ndvi_health=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_ndvi_health_service=lambda: mock_service + ) + + response = self.client.post( + "/ndvi-health/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") diff --git a/Modules/Ai/location_data/test_openeo_service.py b/Modules/Ai/location_data/test_openeo_service.py new file mode 100644 index 0000000..d1394f4 --- /dev/null +++ b/Modules/Ai/location_data/test_openeo_service.py @@ -0,0 +1,66 @@ +from decimal import Decimal + +from django.test import SimpleTestCase + +from location_data.openeo_service import ( + build_empty_metric_payload, + linear_to_db, + merge_metric_results, + parse_aggregate_spatial_response, +) + + +class OpenEOServiceParsingTests(SimpleTestCase): + def test_parse_feature_collection_results(self): + payload = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "cell-1", + "properties": {"mean": 0.61}, + }, + { + "type": "Feature", + "id": "cell-2", + "properties": {"mean": 0.47}, + }, + ], + } + + result = parse_aggregate_spatial_response(payload, "ndvi") + + self.assertEqual(result["cell-1"]["ndvi"], 0.61) + self.assertEqual(result["cell-2"]["ndvi"], 0.47) + + def test_parse_mapping_results(self): + payload = { + "cell-1": {"mean": 12.4}, + "cell-2": {"mean": 15.1}, + } + + result = parse_aggregate_spatial_response(payload, "lst_c") + + self.assertEqual(result["cell-1"]["lst_c"], 12.4) + self.assertEqual(result["cell-2"]["lst_c"], 15.1) + + def test_linear_to_db(self): + self.assertEqual(linear_to_db(10.0), 10.0) + self.assertEqual(linear_to_db(Decimal("1.0")), 0.0) + self.assertIsNone(linear_to_db(0)) + self.assertIsNone(linear_to_db(-1)) + + def test_merge_metric_results(self): + target = {"cell-1": build_empty_metric_payload()} + + merge_metric_results( + target, + { + "cell-1": {"ndvi": 0.5}, + "cell-2": {"ndwi": 0.2}, + }, + ) + + self.assertEqual(target["cell-1"]["ndvi"], 0.5) + self.assertEqual(target["cell-2"]["ndwi"], 0.2) + self.assertIn("soil_vv_db", target["cell-2"]) diff --git a/Modules/Ai/location_data/test_remote_sensing_api.py b/Modules/Ai/location_data/test_remote_sensing_api.py new file mode 100644 index 0000000..da4930f --- /dev/null +++ b/Modules/Ai/location_data/test_remote_sensing_api.py @@ -0,0 +1,265 @@ +from datetime import date +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from location_data.models import ( + AnalysisGridCell, + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingClusterAssignment, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) + + +@override_settings(ROOT_URLCONF="location_data.urls") +class RemoteSensingApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3900, 35.6890], + [51.3900, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + } + self.location = SoilLocation.objects.create( + latitude="35.689200", + longitude="51.389000", + farm_boundary=self.boundary, + ) + self.location.set_input_block_count(1) + self.location.save(update_fields=["input_block_count", "block_layout", "updated_at"]) + self.subdivision = BlockSubdivision.objects.create( + soil_location=self.location, + block_code="block-1", + source_boundary=self.boundary, + chunk_size_sqm=900, + status="created", + ) + + def test_post_remote_sensing_returns_404_when_location_missing(self): + response = self.client.post( + "/remote-sensing/", + data={ + "lat": 35.7000, + "lon": 51.4000, + "start_date": "2025-01-01", + "end_date": "2025-01-31", + }, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "location پیدا نشد.") + + @patch("location_data.views.run_remote_sensing_analysis_task.delay") + def test_post_remote_sensing_enqueues_task_and_returns_processing(self, mock_delay): + mock_delay.return_value = SimpleNamespace(id="task-123") + + response = self.client.post( + "/remote-sensing/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "block_code": "block-1", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "force_refresh": False, + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + payload = response.json()["data"] + self.assertEqual(payload["status"], "processing") + self.assertEqual(payload["source"], "processing") + self.assertEqual(payload["task_id"], "task-123") + self.assertEqual(payload["block_code"], "block-1") + self.assertEqual(payload["summary"]["cell_count"], 0) + run = RemoteSensingRun.objects.get(id=payload["run"]["id"]) + self.assertEqual(run.block_code, "block-1") + self.assertEqual(run.status, RemoteSensingRun.STATUS_PENDING) + self.assertEqual(run.metadata["stage"], "queued") + self.assertEqual(run.metadata["selected_features"], []) + mock_delay.assert_called_once() + + def test_get_remote_sensing_returns_processing_when_run_exists_without_results(self): + RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + status=RemoteSensingRun.STATUS_RUNNING, + metadata={"task_id": "task-123"}, + ) + + response = self.client.get( + "/remote-sensing/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "block_code": "block-1", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + }, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "processing") + self.assertEqual(payload["source"], "processing") + self.assertEqual(payload["cells"], []) + self.assertEqual(payload["run"]["status"], RemoteSensingRun.STATUS_RUNNING) + + def test_get_remote_sensing_returns_cached_results(self): + run = RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + status=RemoteSensingRun.STATUS_SUCCESS, + ) + cell = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-1", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689500", + centroid_lon="51.389500", + ) + AnalysisGridObservation.objects.create( + cell=cell, + run=run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.61, + ndwi=0.22, + lst_c=24.5, + soil_vv=0.13, + soil_vv_db=-8.860566, + dem_m=1550.0, + slope_deg=4.2, + metadata={"backend_name": "openeo"}, + ) + + response = self.client.get( + "/remote-sensing/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "block_code": "block-1", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + }, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "success") + self.assertEqual(payload["source"], "database") + self.assertEqual(payload["summary"]["cell_count"], 1) + self.assertEqual(payload["summary"]["ndvi_mean"], 0.61) + self.assertEqual(payload["summary"]["soil_vv_db_mean"], -8.860566) + self.assertEqual(len(payload["cells"]), 1) + self.assertEqual(payload["cells"][0]["cell_code"], "cell-1") + + def test_run_status_endpoint_returns_normalized_status(self): + run = RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + status=RemoteSensingRun.STATUS_SUCCESS, + metadata={"stage": "completed", "selected_features": ["ndvi"]}, + ) + + response = self.client.get(f"/remote-sensing/runs/{run.id}/status/") + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "completed") + self.assertEqual(payload["run"]["pipeline_status"], "completed") + self.assertEqual(payload["run"]["stage"], "completed") + self.assertEqual(payload["run"]["selected_features"], ["ndvi"]) + + def test_run_result_endpoint_returns_paginated_assignments(self): + run = RemoteSensingRun.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + status=RemoteSensingRun.STATUS_SUCCESS, + metadata={"stage": "completed"}, + ) + cell = AnalysisGridCell.objects.create( + soil_location=self.location, + block_subdivision=self.subdivision, + block_code="block-1", + cell_code="cell-1", + chunk_size_sqm=900, + geometry=self.boundary, + centroid_lat="35.689500", + centroid_lon="51.389500", + ) + AnalysisGridObservation.objects.create( + cell=cell, + run=run, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + ndvi=0.61, + ndwi=0.22, + lst_c=24.5, + soil_vv=0.13, + soil_vv_db=-8.860566, + dem_m=1550.0, + slope_deg=4.2, + metadata={"backend_name": "openeo"}, + ) + result = RemoteSensingSubdivisionResult.objects.create( + soil_location=self.location, + run=run, + block_subdivision=self.subdivision, + block_code="block-1", + chunk_size_sqm=900, + temporal_start=date(2025, 1, 1), + temporal_end=date(2025, 1, 31), + cluster_count=1, + selected_features=["ndvi"], + metadata={"used_cell_count": 1, "skipped_cell_count": 0}, + ) + RemoteSensingClusterAssignment.objects.create( + result=result, + cell=cell, + cluster_label=0, + raw_feature_values={"ndvi": 0.61}, + scaled_feature_values={"ndvi": 0.0}, + ) + + response = self.client.get(f"/remote-sensing/runs/{run.id}/result/", data={"page": 1, "page_size": 10}) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["status"], "completed") + self.assertEqual(payload["subdivision_result"]["cluster_count"], 1) + self.assertEqual(len(payload["subdivision_result"]["assignments"]), 1) + self.assertEqual(payload["pagination"]["assignments"]["total_items"], 1) diff --git a/Modules/Ai/location_data/test_soil_api.py b/Modules/Ai/location_data/test_soil_api.py new file mode 100644 index 0000000..21e20e9 --- /dev/null +++ b/Modules/Ai/location_data/test_soil_api.py @@ -0,0 +1,126 @@ +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from location_data.models import BlockSubdivision, SoilLocation + + +@override_settings(ROOT_URLCONF="location_data.urls") +class SoilDataApiTests(TestCase): + def setUp(self): + self.client = APIClient() + self.boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3902, 35.6890], + [51.3902, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + } + self.block_boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3896, 35.6890], + [51.3896, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + } + + def test_post_creates_default_single_block_layout(self): + response = self.client.post( + "/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "farm_boundary": self.boundary, + "blocks": [ + { + "block_code": "block-1", + "boundary": self.block_boundary, + } + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["source"], "created") + self.assertEqual(payload["input_block_count"], 1) + self.assertEqual(len(payload["block_layout"]["blocks"]), 1) + self.assertEqual(payload["block_layout"]["blocks"][0]["boundary"], self.block_boundary) + self.assertEqual(payload["block_layout"]["algorithm_status"], "pending") + self.assertEqual(len(payload["block_subdivisions"]), 1) + self.assertEqual(payload["block_subdivisions"][0]["status"], "defined") + self.assertEqual(payload["satellite_snapshots"][0]["status"], "missing") + + def test_post_updates_block_layout_from_input(self): + SoilLocation.objects.create( + latitude="35.689200", + longitude="51.389000", + ) + + response = self.client.post( + "/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "farm_boundary": self.boundary, + "blocks": [ + {"block_code": "block-a", "boundary": self.block_boundary}, + {"block_code": "block-b", "boundary": self.block_boundary}, + ], + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["input_block_count"], 2) + self.assertEqual(len(payload["block_layout"]["blocks"]), 2) + self.assertEqual(len(payload["block_subdivisions"]), 2) + + location = SoilLocation.objects.get(latitude="35.689200", longitude="51.389000") + self.assertEqual(location.input_block_count, 2) + self.assertEqual(len(location.block_layout["blocks"]), 2) + self.assertEqual(location.block_layout["algorithm_status"], "pending") + self.assertTrue( + BlockSubdivision.objects.filter( + soil_location=location, + block_code="block-a", + status="defined", + ).exists() + ) + + def test_get_returns_stored_subdivisions_without_processing(self): + self.client.post( + "/", + data={ + "lat": 35.6892, + "lon": 51.3890, + "farm_boundary": self.boundary, + "blocks": [ + { + "block_code": "block-1", + "boundary": self.block_boundary, + } + ], + }, + format="json", + ) + + response = self.client.get( + "/", + data={"lat": 35.6892, "lon": 51.3890}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["source"], "database") + self.assertEqual(len(response.json()["data"]["block_subdivisions"]), 1) diff --git a/Modules/Ai/location_data/urls.py b/Modules/Ai/location_data/urls.py new file mode 100644 index 0000000..7945824 --- /dev/null +++ b/Modules/Ai/location_data/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + NdviHealthView, + RemoteSensingAnalysisView, + RemoteSensingRunResultView, + RemoteSensingRunStatusView, + SoilDataView, +) + +urlpatterns = [ + path("", SoilDataView.as_view(), name="soil-data"), + path("remote-sensing/", RemoteSensingAnalysisView.as_view(), name="remote-sensing"), + path("remote-sensing/runs//status/", RemoteSensingRunStatusView.as_view(), name="remote-sensing-run-status"), + path("remote-sensing/runs//result/", RemoteSensingRunResultView.as_view(), name="remote-sensing-run-result"), + path("ndvi-health/", NdviHealthView.as_view(), name="ndvi-health"), +] diff --git a/Modules/Ai/location_data/views.py b/Modules/Ai/location_data/views.py new file mode 100644 index 0000000..cc93ee9 --- /dev/null +++ b/Modules/Ai/location_data/views.py @@ -0,0 +1,938 @@ +from django.apps import apps +from django.core.paginator import EmptyPage, Paginator +from django.db.models import Avg +from django.db import transaction +from rest_framework import status +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import ( + build_envelope_serializer, + build_response, +) +from .models import ( + AnalysisGridObservation, + BlockSubdivision, + RemoteSensingRun, + RemoteSensingSubdivisionResult, + SoilLocation, +) +from .serializers import ( + BlockSubdivisionSerializer, + NdviHealthRequestSerializer, + NdviHealthResponseSerializer, + RemoteSensingCellObservationSerializer, + RemoteSensingResponseSerializer, + RemoteSensingResultQuerySerializer, + RemoteSensingRunResultResponseSerializer, + RemoteSensingRunSerializer, + RemoteSensingRunStatusResponseSerializer, + RemoteSensingSummarySerializer, + RemoteSensingSubdivisionResultSerializer, + RemoteSensingTriggerSerializer, + SoilDataRequestSerializer, + SoilLocationResponseSerializer, +) +from .tasks import run_remote_sensing_analysis_task + +MAX_REMOTE_SENSING_PAGE_SIZE = 200 + +SoilLocationPayloadSerializer = inline_serializer( + name="SoilLocationPayloadSerializer", + fields={ + "source": drf_serializers.CharField(), + "id": drf_serializers.IntegerField(), + "lon": drf_serializers.DecimalField(max_digits=9, decimal_places=6), + "lat": drf_serializers.DecimalField(max_digits=9, decimal_places=6), + "input_block_count": drf_serializers.IntegerField(), + "farm_boundary": drf_serializers.JSONField(), + "block_layout": drf_serializers.JSONField(), + "block_subdivisions": BlockSubdivisionSerializer(many=True), + "satellite_snapshots": drf_serializers.JSONField(), + }, +) +SoilDataResponseSerializer = build_envelope_serializer( + "SoilDataResponseSerializer", + SoilLocationPayloadSerializer, +) +SoilErrorResponseSerializer = build_envelope_serializer( + "SoilErrorResponseSerializer", + data_required=False, + allow_null=True, +) +NdviHealthEnvelopeSerializer = build_envelope_serializer( + "NdviHealthEnvelopeSerializer", + NdviHealthResponseSerializer, +) +RemoteSensingEnvelopeSerializer = build_envelope_serializer( + "RemoteSensingEnvelopeSerializer", + RemoteSensingResponseSerializer, +) +RemoteSensingQueuedEnvelopeSerializer = build_envelope_serializer( + "RemoteSensingQueuedEnvelopeSerializer", + inline_serializer( + name="RemoteSensingQueuedPayloadSerializer", + fields={ + "status": drf_serializers.CharField(), + "source": drf_serializers.CharField(), + "location": drf_serializers.JSONField(), + "block_code": drf_serializers.CharField(), + "chunk_size_sqm": drf_serializers.IntegerField(allow_null=True), + "temporal_extent": drf_serializers.JSONField(), + "summary": RemoteSensingSummarySerializer(), + "cells": drf_serializers.JSONField(), + "run": drf_serializers.JSONField(allow_null=True), + "task_id": drf_serializers.CharField(), + }, + ), +) +RemoteSensingRunStatusEnvelopeSerializer = build_envelope_serializer( + "RemoteSensingRunStatusEnvelopeSerializer", + RemoteSensingRunStatusResponseSerializer, +) +RemoteSensingRunResultEnvelopeSerializer = build_envelope_serializer( + "RemoteSensingRunResultEnvelopeSerializer", + RemoteSensingRunResultResponseSerializer, +) + + +class SoilDataView(APIView): + """ + ثبت مختصات گوشه‌های مزرعه و بلوک‌های تعریف‌شده توسط کشاورز. + """ + + @extend_schema( + tags=["Soil Data"], + summary="خواندن ساختار مزرعه و بلوک‌ها (GET)", + description="با ارسال lat و lon، ساختار ذخیره‌شده مزرعه، بلوک‌ها و آخرین خلاصه سنجش‌ازدور هر بلوک بازگردانده می‌شود.", + parameters=[ + { + "name": "lat", + "in": "query", + "required": True, + "schema": {"type": "number"}, + "description": "عرض جغرافیایی", + }, + { + "name": "lon", + "in": "query", + "required": True, + "schema": {"type": "number"}, + "description": "طول جغرافیایی", + }, + { + "name": "block_code", + "in": "query", + "required": False, + "schema": {"type": "string", "default": "block-1"}, + "description": "در GET فقط برای فیلتر کلاینتی است و الگوریتمی اجرا نمی‌کند.", + }, + ], + responses={ + 200: build_response( + SoilDataResponseSerializer, + "ساختار بلوک‌های زمین از دیتابیس بازگردانده شد.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "location موردنظر پیدا نشد.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "پارامترهای ورودی نامعتبر هستند.", + ), + }, + ) + def get(self, request): + serializer = SoilDataRequestSerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + lat = serializer.validated_data["lat"] + lon = serializer.validated_data["lon"] + location = _get_location_by_lat_lon(lat, lon, prefetch=True) + if location is None: + return Response( + {"code": 404, "msg": "location پیدا نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + data_serializer = SoilLocationResponseSerializer(location) + return Response( + {"code": 200, "msg": "success", "data": {"source": "database", **data_serializer.data}}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Soil Data"], + summary="ثبت مزرعه و بلوک‌های کشاورز (POST)", + description="مختصات گوشه‌های مزرعه و boundary هر بلوک کشاورز ذخیره می‌شود. هیچ subdivision سنکرونی اجرا نمی‌شود.", + request=SoilDataRequestSerializer, + responses={ + 200: build_response( + SoilDataResponseSerializer, + "اطلاعات location ذخیره یا به‌روزرسانی شد.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "پارامترهای ورودی نامعتبر هستند.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "lat": 35.6892, + "lon": 51.3890, + "farm_boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3902, 35.6890], + [51.3902, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + }, + "blocks": [ + { + "block_code": "block-1", + "boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.3890, 35.6890], + [51.3896, 35.6890], + [51.3896, 35.6900], + [51.3890, 35.6900], + [51.3890, 35.6890], + ] + ], + }, + } + ], + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = SoilDataRequestSerializer( + data=request.data, + context={"require_farm_boundary": True}, + ) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + lat = serializer.validated_data["lat"] + lon = serializer.validated_data["lon"] + block_count = serializer.validated_data.get("block_count", 1) + farm_boundary = serializer.validated_data.get("farm_boundary") + blocks = serializer.validated_data.get("blocks") or [] + lat_rounded = round(lat, 6) + lon_rounded = round(lon, 6) + + location, created = SoilLocation.objects.get_or_create( + latitude=lat_rounded, + longitude=lon_rounded, + defaults={ + "input_block_count": block_count, + "farm_boundary": farm_boundary or {}, + }, + ) + if created: + location.set_input_block_count(block_count, blocks=blocks or None) + if farm_boundary is not None: + location.farm_boundary = farm_boundary + location.save(update_fields=["input_block_count", "farm_boundary", "block_layout", "updated_at"]) + else: + changed_fields = [] + if block_count != location.input_block_count or blocks: + location.set_input_block_count(block_count, blocks=blocks or None) + changed_fields.extend(["input_block_count", "block_layout"]) + if farm_boundary is not None and location.farm_boundary != farm_boundary: + location.farm_boundary = farm_boundary + changed_fields.append("farm_boundary") + if changed_fields: + changed_fields.append("updated_at") + location.save(update_fields=changed_fields) + + if not (farm_boundary or location.farm_boundary): + return Response( + { + "code": 400, + "msg": "داده نامعتبر.", + "data": {"farm_boundary": ["برای ثبت location باید گوشه‌های کل زمین ارسال یا قبلاً ذخیره شده باشد."]}, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + _sync_defined_blocks(location, blocks) + + location = _get_location_by_lat_lon(lat, lon, prefetch=True) + data_serializer = SoilLocationResponseSerializer(location) + return Response( + { + "code": 200, + "msg": "success", + "data": { + "source": "created" if created else "database", + **data_serializer.data, + }, + }, + status=status.HTTP_200_OK, + ) + + +class NdviHealthView(APIView): + @extend_schema( + tags=["Soil Data"], + summary="دریافت NDVI سلامت مزرعه", + description="با دریافت farm_uuid، داده NDVI سلامت پوشش گیاهی مزرعه را به صورت مستقل از dashboard برمی گرداند.", + request=NdviHealthRequestSerializer, + responses={ + 200: build_response( + NdviHealthEnvelopeSerializer, + "داده NDVI مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "مزرعه یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست NDVI", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = NdviHealthRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("location_data").get_ndvi_health_service() + try: + data = service.get_ndvi_health( + farm_uuid=str(serializer.validated_data["farm_uuid"]) + ) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class RemoteSensingAnalysisView(APIView): + @extend_schema( + tags=["Soil Data"], + summary="اجرای async تحلیل سنجش‌ازدور و subdivision داده‌محور", + description="برای location موجود، pipeline کامل grid + openEO + observation persistence + KMeans clustering در Celery صف می‌شود و sync اجرا نمی‌شود.", + request=RemoteSensingTriggerSerializer, + responses={ + 202: build_response( + RemoteSensingQueuedEnvelopeSerializer, + "درخواست تحلیل سنجش‌ازدور در صف قرار گرفت.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "location موردنظر پیدا نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست remote sensing", + value={ + "lat": 35.6892, + "lon": 51.3890, + "block_code": "block-1", + "start_date": "2025-01-01", + "end_date": "2025-01-31", + "force_refresh": False, + "cluster_count": 3, + "selected_features": ["ndvi", "ndwi", "soil_vv_db"], + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = RemoteSensingTriggerSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = serializer.validated_data + location = _get_location_by_lat_lon(payload["lat"], payload["lon"], prefetch=True) + if location is None: + return Response( + {"code": 404, "msg": "location پیدا نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + block_code = str(payload.get("block_code", "") or "").strip() + run = RemoteSensingRun.objects.create( + soil_location=location, + block_code=block_code, + chunk_size_sqm=_resolve_chunk_size_for_location(location, block_code), + temporal_start=payload["start_date"], + temporal_end=payload["end_date"], + status=RemoteSensingRun.STATUS_PENDING, + metadata={ + "requested_via": "api", + "status_label": "pending", + "cluster_count": payload.get("cluster_count"), + "selected_features": payload.get("selected_features") or [], + }, + ) + task_result = run_remote_sensing_analysis_task.delay( + soil_location_id=location.id, + block_code=block_code, + temporal_start=payload["start_date"].isoformat(), + temporal_end=payload["end_date"].isoformat(), + force_refresh=payload.get("force_refresh", False), + run_id=run.id, + cluster_count=payload.get("cluster_count"), + selected_features=payload.get("selected_features"), + ) + run.metadata = {**(run.metadata or {}), "task_id": task_result.id} + run.save(update_fields=["metadata", "updated_at"]) + + location_data = SoilLocationResponseSerializer(location).data + response_payload = { + "status": "processing", + "source": "processing", + "location": location_data, + "block_code": block_code, + "chunk_size_sqm": run.chunk_size_sqm, + "temporal_extent": { + "start_date": payload["start_date"].isoformat(), + "end_date": payload["end_date"].isoformat(), + }, + "summary": _empty_remote_sensing_summary(), + "cells": [], + "run": RemoteSensingRunSerializer(run).data, + "task_id": task_result.id, + } + return Response( + {"code": 202, "msg": "تحلیل سنجش‌ازدور در صف قرار گرفت.", "data": response_payload}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema( + tags=["Soil Data"], + summary="خواندن نتایج cache شده سنجش‌ازدور و subdivision", + description="فقط نتایج ذخیره‌شده remote sensing و clustering را برمی‌گرداند و هیچ پردازش sync اجرا نمی‌کند.", + parameters=[ + {"name": "lat", "in": "query", "required": True, "schema": {"type": "number"}}, + {"name": "lon", "in": "query", "required": True, "schema": {"type": "number"}}, + {"name": "block_code", "in": "query", "required": False, "schema": {"type": "string"}}, + {"name": "start_date", "in": "query", "required": True, "schema": {"type": "string", "format": "date"}}, + {"name": "end_date", "in": "query", "required": True, "schema": {"type": "string", "format": "date"}}, + {"name": "page", "in": "query", "required": False, "schema": {"type": "integer", "default": 1}}, + {"name": "page_size", "in": "query", "required": False, "schema": {"type": "integer", "default": 100}}, + ], + responses={ + 200: build_response( + RemoteSensingEnvelopeSerializer, + "نتایج cache شده remote sensing بازگردانده شد.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "location موردنظر پیدا نشد.", + ), + 400: build_response( + SoilErrorResponseSerializer, + "داده ورودی نامعتبر است.", + ), + }, + ) + def get(self, request): + serializer = RemoteSensingResultQuerySerializer(data=request.query_params) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + payload = serializer.validated_data + location = _get_location_by_lat_lon(payload["lat"], payload["lon"], prefetch=True) + if location is None: + return Response( + {"code": 404, "msg": "location پیدا نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + block_code = str(payload.get("block_code", "") or "").strip() + observations = _get_remote_sensing_observations( + location=location, + block_code=block_code, + start_date=payload["start_date"], + end_date=payload["end_date"], + ) + run = _get_latest_remote_sensing_run( + location=location, + block_code=block_code, + start_date=payload["start_date"], + end_date=payload["end_date"], + ) + subdivision_result = _get_remote_sensing_subdivision_result( + location=location, + block_code=block_code, + start_date=payload["start_date"], + end_date=payload["end_date"], + ) + + if not observations.exists(): + processing = run is not None and run.status in { + RemoteSensingRun.STATUS_PENDING, + RemoteSensingRun.STATUS_RUNNING, + } + response_payload = { + "status": "processing" if processing else "not_found", + "source": "processing" if processing else "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": block_code, + "chunk_size_sqm": getattr(run, "chunk_size_sqm", None), + "temporal_extent": { + "start_date": payload["start_date"].isoformat(), + "end_date": payload["end_date"].isoformat(), + }, + "summary": _empty_remote_sensing_summary(), + "cells": [], + "run": RemoteSensingRunSerializer(run).data if run else None, + "subdivision_result": None, + } + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + paginated_observations = _paginate_observations( + observations, + page=payload["page"], + page_size=payload["page_size"], + ) + paginated_assignments = [] + pagination = {"cells": paginated_observations["pagination"]} + if subdivision_result is not None: + paginated = _paginate_assignments( + subdivision_result, + page=payload["page"], + page_size=payload["page_size"], + ) + paginated_assignments = paginated["items"] + pagination["assignments"] = paginated["pagination"] + + cells_data = RemoteSensingCellObservationSerializer(paginated_observations["items"], many=True).data + subdivision_data = None + if subdivision_result is not None: + subdivision_data = RemoteSensingSubdivisionResultSerializer( + subdivision_result, + context={"paginated_assignments": paginated_assignments}, + ).data + + response_payload = { + "status": "success", + "source": "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": block_code, + "chunk_size_sqm": observations.first().cell.chunk_size_sqm, + "temporal_extent": { + "start_date": payload["start_date"].isoformat(), + "end_date": payload["end_date"].isoformat(), + }, + "summary": _build_remote_sensing_summary(observations), + "cells": cells_data, + "run": RemoteSensingRunSerializer(run).data if run else None, + "subdivision_result": subdivision_data, + } + if pagination is not None: + response_payload["pagination"] = pagination + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + +class RemoteSensingRunStatusView(APIView): + @extend_schema( + tags=["Soil Data"], + summary="وضعیت run تحلیل سنجش‌ازدور", + description="وضعیت async pipeline را با شناسه run برمی‌گرداند.", + responses={ + 200: build_response( + RemoteSensingRunStatusEnvelopeSerializer, + "وضعیت run بازگردانده شد.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "run موردنظر پیدا نشد.", + ), + }, + ) + def get(self, request, run_id): + run = RemoteSensingRun.objects.filter(pk=run_id).select_related("soil_location").first() + if run is None: + return Response( + {"code": 404, "msg": "run پیدا نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + task_id = (run.metadata or {}).get("task_id") + response_payload = { + "status": RemoteSensingRunSerializer(run).data["status_label"], + "source": "database", + "run": RemoteSensingRunSerializer(run).data, + "task_id": task_id, + } + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + +class RemoteSensingRunResultView(APIView): + @extend_schema( + tags=["Soil Data"], + summary="نتیجه نهایی run تحلیل سنجش‌ازدور", + description="نتایج observation و subdivision داده‌محور را با شناسه run برمی‌گرداند.", + parameters=[ + {"name": "page", "in": "query", "required": False, "schema": {"type": "integer", "default": 1}}, + {"name": "page_size", "in": "query", "required": False, "schema": {"type": "integer", "default": 100}}, + ], + responses={ + 200: build_response( + RemoteSensingRunResultEnvelopeSerializer, + "نتیجه run بازگردانده شد.", + ), + 404: build_response( + SoilErrorResponseSerializer, + "run موردنظر پیدا نشد.", + ), + }, + ) + def get(self, request, run_id): + page = _safe_positive_int(request.query_params.get("page"), default=1) + page_size = min(_safe_positive_int(request.query_params.get("page_size"), default=100), MAX_REMOTE_SENSING_PAGE_SIZE) + run = ( + RemoteSensingRun.objects.filter(pk=run_id) + .select_related("soil_location") + .first() + ) + if run is None: + return Response( + {"code": 404, "msg": "run پیدا نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + location = _get_location_by_lat_lon(run.soil_location.latitude, run.soil_location.longitude, prefetch=True) + observations = _get_remote_sensing_observations( + location=run.soil_location, + block_code=run.block_code, + start_date=run.temporal_start, + end_date=run.temporal_end, + ) + subdivision_result = getattr(run, "subdivision_result", None) + + if not observations.exists(): + response_payload = { + "status": RemoteSensingRunSerializer(run).data["status_label"], + "source": "processing" if run.status in {RemoteSensingRun.STATUS_PENDING, RemoteSensingRun.STATUS_RUNNING} else "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": run.block_code, + "chunk_size_sqm": run.chunk_size_sqm, + "temporal_extent": { + "start_date": run.temporal_start.isoformat() if run.temporal_start else None, + "end_date": run.temporal_end.isoformat() if run.temporal_end else None, + }, + "summary": _empty_remote_sensing_summary(), + "cells": [], + "run": RemoteSensingRunSerializer(run).data, + "subdivision_result": None, + } + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + paginated_observations = _paginate_observations( + observations, + page=page, + page_size=page_size, + ) + paginated_assignments = [] + pagination = {"cells": paginated_observations["pagination"]} + if subdivision_result is not None: + paginated = _paginate_assignments( + subdivision_result, + page=page, + page_size=page_size, + ) + paginated_assignments = paginated["items"] + pagination["assignments"] = paginated["pagination"] + + subdivision_data = None + if subdivision_result is not None: + subdivision_data = RemoteSensingSubdivisionResultSerializer( + subdivision_result, + context={"paginated_assignments": paginated_assignments}, + ).data + + response_payload = { + "status": RemoteSensingRunSerializer(run).data["status_label"], + "source": "database", + "location": SoilLocationResponseSerializer(location).data, + "block_code": run.block_code, + "chunk_size_sqm": run.chunk_size_sqm, + "temporal_extent": { + "start_date": run.temporal_start.isoformat() if run.temporal_start else None, + "end_date": run.temporal_end.isoformat() if run.temporal_end else None, + }, + "summary": _build_remote_sensing_summary(observations), + "cells": RemoteSensingCellObservationSerializer(paginated_observations["items"], many=True).data, + "run": RemoteSensingRunSerializer(run).data, + "subdivision_result": subdivision_data, + } + if pagination is not None: + response_payload["pagination"] = pagination + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + +def _get_location_by_lat_lon(lat, lon, *, prefetch: bool = False): + lat_rounded = round(lat, 6) + lon_rounded = round(lon, 6) + queryset = SoilLocation.objects.filter(latitude=lat_rounded, longitude=lon_rounded) + if prefetch: + queryset = queryset.prefetch_related("block_subdivisions") + return queryset.first() + + +def _sync_defined_blocks(location: SoilLocation, blocks: list[dict]) -> None: + if not blocks: + return + + with transaction.atomic(): + for index, block in enumerate(blocks): + block_code = str(block.get("block_code") or f"block-{index + 1}").strip() + boundary = block.get("boundary") or {} + BlockSubdivision.objects.update_or_create( + soil_location=location, + block_code=block_code, + defaults={ + "source_boundary": boundary, + "chunk_size_sqm": 900, + "status": "defined", + "metadata": { + "definition_source": "farmer_input", + "order": int(block.get("order") or index + 1), + }, + }, + ) + + +def _resolve_chunk_size_for_location(location: SoilLocation, block_code: str) -> int | None: + if block_code: + subdivision = location.block_subdivisions.filter(block_code=block_code).first() + if subdivision is not None: + return subdivision.chunk_size_sqm + block_layout = location.block_layout or {} + if not block_code: + return block_layout.get("analysis_grid_summary", {}).get("chunk_size_sqm") + for block in block_layout.get("blocks", []): + if block.get("block_code") == block_code: + return block.get("analysis_grid_summary", {}).get("chunk_size_sqm") + return None + + +def _get_remote_sensing_observations(*, location, block_code: str, start_date, end_date): + queryset = ( + AnalysisGridObservation.objects.select_related("cell", "run") + .filter( + cell__soil_location=location, + temporal_start=start_date, + temporal_end=end_date, + ) + .order_by("cell__cell_code") + ) + return queryset.filter(cell__block_code=block_code or "") + + +def _get_latest_remote_sensing_run(*, location, block_code: str, start_date, end_date): + return ( + RemoteSensingRun.objects.filter( + soil_location=location, + block_code=block_code or "", + temporal_start=start_date, + temporal_end=end_date, + ) + .order_by("-created_at", "-id") + .first() + ) + + +def _get_remote_sensing_subdivision_result(*, location, block_code: str, start_date, end_date): + return ( + RemoteSensingSubdivisionResult.objects.filter( + soil_location=location, + block_code=block_code or "", + temporal_start=start_date, + temporal_end=end_date, + ) + .select_related("run") + .prefetch_related("assignments__cell") + .order_by("-created_at", "-id") + .first() + ) + + +def _build_remote_sensing_summary(observations): + aggregates = observations.aggregate( + cell_count=Avg("cell_id"), + ndvi_mean=Avg("ndvi"), + ndwi_mean=Avg("ndwi"), + lst_c_mean=Avg("lst_c"), + soil_vv_db_mean=Avg("soil_vv_db"), + dem_m_mean=Avg("dem_m"), + slope_deg_mean=Avg("slope_deg"), + ) + summary = { + "cell_count": observations.count(), + "ndvi_mean": _round_or_none(aggregates.get("ndvi_mean")), + "ndwi_mean": _round_or_none(aggregates.get("ndwi_mean")), + "lst_c_mean": _round_or_none(aggregates.get("lst_c_mean")), + "soil_vv_db_mean": _round_or_none(aggregates.get("soil_vv_db_mean")), + "dem_m_mean": _round_or_none(aggregates.get("dem_m_mean")), + "slope_deg_mean": _round_or_none(aggregates.get("slope_deg_mean")), + } + return summary + + +def _empty_remote_sensing_summary(): + return { + "cell_count": 0, + "ndvi_mean": None, + "ndwi_mean": None, + "lst_c_mean": None, + "soil_vv_db_mean": None, + "dem_m_mean": None, + "slope_deg_mean": None, + } + + +def _round_or_none(value): + if value is None: + return None + return round(float(value), 6) + + +def _paginate_assignments(result: RemoteSensingSubdivisionResult, *, page: int, page_size: int) -> dict: + page_size = min(max(page_size, 1), MAX_REMOTE_SENSING_PAGE_SIZE) + assignments = result.assignments.select_related("cell").order_by("cluster_label", "cell__cell_code") + paginator = Paginator(assignments, page_size) + if paginator.count == 0: + return { + "items": [], + "pagination": { + "page": 1, + "page_size": page_size, + "total_items": 0, + "total_pages": 0, + "has_next": False, + "has_previous": False, + }, + } + try: + page_obj = paginator.page(page) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + 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 _safe_positive_int(value, *, default: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return parsed if parsed > 0 else default + + + +def _paginate_observations(observations, *, page: int, page_size: int) -> dict: + page_size = min(max(page_size, 1), MAX_REMOTE_SENSING_PAGE_SIZE) + paginator = Paginator(observations, page_size) + if paginator.count == 0: + return { + "items": [], + "pagination": { + "page": 1, + "page_size": page_size, + "total_items": 0, + "total_pages": 0, + "has_next": False, + "has_previous": False, + }, + } + try: + page_obj = paginator.page(page) + except EmptyPage: + page_obj = paginator.page(paginator.num_pages) + 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(), + }, + } diff --git a/Modules/Ai/manage.py b/Modules/Ai/manage.py new file mode 100644 index 0000000..d28672e --- /dev/null +++ b/Modules/Ai/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/Modules/Ai/pest_disease/__init__.py b/Modules/Ai/pest_disease/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/pest_disease/apps.py b/Modules/Ai/pest_disease/apps.py new file mode 100644 index 0000000..ec241dd --- /dev/null +++ b/Modules/Ai/pest_disease/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PestDiseaseConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pest_disease" + verbose_name = "Pest & Disease" diff --git a/Modules/Ai/pest_disease/serializers.py b/Modules/Ai/pest_disease/serializers.py new file mode 100644 index 0000000..96090d8 --- /dev/null +++ b/Modules/Ai/pest_disease/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + + +class PestDiseaseDetectionRequestSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه") + sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه") + query = serializers.CharField(required=False, allow_blank=True, help_text="توضیح اختیاری") + image_urls = serializers.JSONField(required=False, help_text="آرایه URL تصاویر") + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + attrs["farm_uuid"] = farm_uuid + return attrs + + +class PestDiseaseRiskRequestSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, help_text="شناسه یکتای مزرعه") + sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد") + query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری") + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."}) + attrs["farm_uuid"] = farm_uuid + return attrs diff --git a/Modules/Ai/pest_disease/urls.py b/Modules/Ai/pest_disease/urls.py new file mode 100644 index 0000000..c00c809 --- /dev/null +++ b/Modules/Ai/pest_disease/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import PestDiseaseDetectionView, PestDiseaseRiskView + + +urlpatterns = [ + path("detect/", PestDiseaseDetectionView.as_view(), name="pest-disease-detect"), + path("risk/", PestDiseaseRiskView.as_view(), name="pest-disease-risk"), +] diff --git a/Modules/Ai/pest_disease/views.py b/Modules/Ai/pest_disease/views.py new file mode 100644 index 0000000..b5008e0 --- /dev/null +++ b/Modules/Ai/pest_disease/views.py @@ -0,0 +1,165 @@ +import json + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError +from rag.chat import encode_uploaded_image +from rag.services import get_pest_disease_detection, get_pest_disease_risk + +from .serializers import ( + PestDiseaseDetectionRequestSerializer, + PestDiseaseRiskRequestSerializer, +) + + +PestDiseaseValidationErrorSerializer = build_envelope_serializer( + "PestDiseaseValidationErrorSerializer", + data_required=False, + allow_null=True, +) +PestDiseaseDetectionResponseSerializer = build_envelope_serializer( + "PestDiseaseDetectionResponseSerializer", + data_schema=None, +) +PestDiseaseRiskResponseSerializer = build_envelope_serializer( + "PestDiseaseRiskResponseSerializer", + data_schema=None, +) +class _ImageMixin: + parser_classes = [JSONParser, MultiPartParser, FormParser] + + def _collect_uploaded_images(self, request): + images = [] + for uploaded in request.FILES.getlist("images"): + images.append(encode_uploaded_image(uploaded)) + single_image = request.FILES.get("image") + if single_image is not None: + images.append(encode_uploaded_image(single_image)) + image_urls = request.data.get("image_urls") + if isinstance(image_urls, str) and image_urls.strip(): + try: + parsed = json.loads(image_urls) + except (json.JSONDecodeError, ValueError): + parsed = [image_urls] + image_urls = parsed + if isinstance(image_urls, list): + for item in image_urls: + if isinstance(item, str) and item.strip(): + images.append({"url": item.strip(), "detail": "auto"}) + elif isinstance(item, dict) and isinstance(item.get("url"), str): + images.append({"url": item["url"].strip(), "detail": item.get("detail", "auto")}) + return images + + +class PestDiseaseDetectionView(_ImageMixin, APIView): + @extend_schema( + tags=["Pest & Disease"], + summary="تشخیص آفت یا بیماری از روی تصویر", + description="با دریافت farm_uuid و حداقل یک تصویر، تصویر گیاه را با کمک RAG بررسی می کند و نتیجه تشخیص را برمی گرداند.", + request=PestDiseaseDetectionRequestSerializer, + responses={ + 200: build_response(PestDiseaseDetectionResponseSerializer, "نتیجه تشخیص آفت/بیماری."), + 400: build_response(PestDiseaseValidationErrorSerializer, "پارامتر ورودی نامعتبر است."), + 500: build_response(PestDiseaseValidationErrorSerializer, "خطا در تحلیل تصویر گیاه."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست تشخیص", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه فرنگی", + "query": "این برگ ها مشکوک به آفت هستند؟", + "image_urls": ["https://example.com/leaf.jpg"], + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = PestDiseaseDetectionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + images = self._collect_uploaded_images(request) + if not images: + return Response( + {"code": 400, "msg": "حداقل یک تصویر باید ارسال شود.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + validated = serializer.validated_data + try: + result = get_pest_disease_detection( + farm_uuid=validated["farm_uuid"], + plant_name=validated.get("plant_name"), + query=validated.get("query"), + images=images, + ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تحلیل تصویر گیاه: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK) + + +class PestDiseaseRiskView(APIView): + @extend_schema( + tags=["Pest & Disease"], + summary="پیش بینی ریسک آفات و بیماری", + description="با دریافت farm_uuid، داده های مزرعه و پایگاه دانش تخصصی را به RAG می دهد و ریسک آفات و بیماری را برمی گرداند.", + request=PestDiseaseRiskRequestSerializer, + responses={ + 200: build_response(PestDiseaseRiskResponseSerializer, "خروجی پیش بینی ریسک آفات و بیماری."), + 400: build_response(PestDiseaseValidationErrorSerializer, "پارامتر ورودی نامعتبر است."), + 500: build_response(PestDiseaseValidationErrorSerializer, "خطا در پیش بینی ریسک آفات و بیماری."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست ریسک", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = PestDiseaseRiskRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + validated = serializer.validated_data + try: + result = get_pest_disease_risk( + farm_uuid=validated["farm_uuid"], + plant_name=validated.get("plant_name"), + growth_stage=validated.get("growth_stage"), + query=validated.get("query"), + ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در پیش بینی ریسک آفات و بیماری: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK) diff --git a/Modules/Ai/plant/PLANT_NAMES_API.md b/Modules/Ai/plant/PLANT_NAMES_API.md new file mode 100644 index 0000000..cdd6487 --- /dev/null +++ b/Modules/Ai/plant/PLANT_NAMES_API.md @@ -0,0 +1,74 @@ +# Plant Names API + +این API فقط لیست نام گیاه‌ها را به همراه آیکون و مراحل رشد برمی‌گرداند. + +## Endpoint + +- `GET /api/plants/names/` + +## کاربرد + +- گرفتن لیست سبک برای dropdown یا selector فرانت +- نمایش نام گیاه +- نمایش `icon` +- نمایش مراحل رشد هر گیاه + +## رفتار API + +- فقط فیلدهای `name`، `icon` و `growth_stages` را برمی‌گرداند +- اگر `growth_stage` برای یک گیاه خالی باشد، API به صورت خودکار این مراحل پیش‌فرض را اضافه و در دیتابیس ذخیره می‌کند: + - `initial` + - `vegetative` + - `flowering` + - `fruiting` + - `maturity` +- اگر `icon` خالی باشد، مقدار پیش‌فرض `leaf` ذخیره و برگردانده می‌شود +- اگر در `growth_profile.stage_thresholds` مرحله‌ای وجود داشته باشد، آن مرحله هم در خروجی `growth_stages` لحاظ می‌شود + +## نمونه درخواست + +```bash +curl -X GET http://localhost:8000/api/plants/names/ +``` + +## نمونه پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "name": "Tomato", + "icon": "leaf", + "growth_stages": [ + "vegetative", + "flowering", + "fruiting" + ] + }, + { + "name": "Pepper", + "icon": "leaf", + "growth_stages": [ + "initial", + "vegetative", + "flowering", + "fruiting", + "maturity" + ] + } + ] +} +``` + +## فیلدهای خروجی + +- `name`: نام گیاه +- `icon`: آیکون گیاه برای فرانت +- `growth_stages`: آرایه‌ای از مراحل رشد گیاه + +## نکته برای فرانت + +- این endpoint برای لیست سبک طراحی شده و مناسب صفحه‌های انتخاب گیاه است +- اگر جزئیات کامل گیاه لازم دارید، از `GET /api/plants/` یا `GET /api/plants/{id}/` استفاده کنید diff --git a/Modules/Ai/plant/__init__.py b/Modules/Ai/plant/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/plant/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/plant/admin.py b/Modules/Ai/plant/admin.py new file mode 100644 index 0000000..1f5842c --- /dev/null +++ b/Modules/Ai/plant/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import Plant + + +@admin.register(Plant) +class PlantAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "light", + "soil", + "temperature", + "planting_season", + "created_at", + ) + list_filter = ("planting_season",) + search_fields = ("name",) + readonly_fields = ("created_at", "updated_at") diff --git a/Modules/Ai/plant/apps.py b/Modules/Ai/plant/apps.py new file mode 100644 index 0000000..8d05f55 --- /dev/null +++ b/Modules/Ai/plant/apps.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import re +from functools import cached_property + +from django.apps import AppConfig + + +class PlantConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "plant" + verbose_name = "Plant" + + @cached_property + def plant_aliases(self) -> dict[str, str]: + return { + "tomato": "گوجه‌فرنگی", + "cucumber": "خیار", + "pepper": "فلفل دلمه‌ای", + "bell pepper": "فلفل دلمه‌ای", + "carrot": "هویج", + "lettuce": "کاهو", + "potato": "سیب‌زمینی", + "onion": "پیاز", + } + + @cached_property + def growth_stage_aliases(self) -> dict[str, str]: + return { + "initial": "initial", + "seedling": "initial", + "establishment": "initial", + "جوانه زنی": "initial", + "جوانه‌زنی": "initial", + "نشا": "initial", + "استقرار": "initial", + "vegetative": "vegetative", + "growth": "vegetative", + "رویشی": "vegetative", + "رشد رویشی": "vegetative", + "flowering": "flowering", + "anthesis": "flowering", + "گلدهی": "flowering", + "گل دهی": "flowering", + "fruiting": "fruiting", + "harvest": "fruiting", + "ripening": "fruiting", + "میوه دهی": "fruiting", + "میوه‌دهی": "fruiting", + "برداشت": "fruiting", + "maturity": "maturity", + "رسیدگی": "maturity", + "بلوغ": "maturity", + } + + def _normalize_lookup_value(self, value: str | None) -> str: + text = (value or "").strip().lower() + if not text: + return "" + + translation_table = str.maketrans( + { + "ي": "ی", + "ك": "ک", + "ة": "ه", + "أ": "ا", + "إ": "ا", + "ؤ": "و", + "ۀ": "ه", + "‌": " ", + "-": " ", + "_": " ", + } + ) + text = text.translate(translation_table) + text = re.sub(r"\s+", " ", text) + return text.strip() + + def resolve_growth_stage(self, growth_stage: str | None) -> str | None: + value = (growth_stage or "").strip() + if not value: + return value + + normalized = self._normalize_lookup_value(value) + return self.growth_stage_aliases.get(normalized, value) + + def resolve_plant_name(self, plant_name: str | None) -> str | None: + from .models import Plant + + value = (plant_name or "").strip() + if not value: + return value + + plant = Plant.objects.filter(name=value).first() or Plant.objects.filter(name__iexact=value).first() + if plant is not None: + return plant.name + + normalized = self._normalize_lookup_value(value) + alias_target = self.plant_aliases.get(normalized) + if alias_target: + aliased_plant = Plant.objects.filter(name=alias_target).first() + if aliased_plant is not None: + return aliased_plant.name + + for plant in Plant.objects.only("name").iterator(): + if self._normalize_lookup_value(plant.name) == normalized: + return plant.name + + return value diff --git a/Modules/Ai/plant/gdd.py b/Modules/Ai/plant/gdd.py new file mode 100644 index 0000000..4649cfd --- /dev/null +++ b/Modules/Ai/plant/gdd.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + + +DEFAULT_GROWTH_PROFILE = { + "base_temperature": 10.0, + "required_gdd_for_maturity": 1200.0, + "stage_thresholds": { + "flowering": 500.0, + "fruiting": 850.0, + }, + "current_cumulative_gdd": 0.0, +} + + +@dataclass +class HarvestPrediction: + current_cumulative_gdd: float + required_gdd_for_maturity: float + remaining_gdd: float + estimated_days_to_harvest: int + predicted_harvest_date: str + predicted_harvest_window: dict[str, str] + daily_gdd_forecast: list[dict[str, float | str]] + active_stage: str | None + + +def resolve_growth_profile(plant: Any | None) -> dict: + profile = getattr(plant, "growth_profile", None) or {} + stage_thresholds = { + **DEFAULT_GROWTH_PROFILE["stage_thresholds"], + **profile.get("stage_thresholds", {}), + } + return { + **DEFAULT_GROWTH_PROFILE, + **profile, + "stage_thresholds": stage_thresholds, + } + + +def calculate_daily_gdd(tmax: float, tmin: float, tbase: float) -> float: + mean_temp = (tmax + tmin) / 2.0 + return round(max(mean_temp - tbase, 0.0), 3) + + +def determine_active_stage(current_cumulative_gdd: float, stage_thresholds: dict[str, float]) -> str | None: + active_stage = None + for stage, threshold in sorted(stage_thresholds.items(), key=lambda item: item[1]): + if current_cumulative_gdd >= float(threshold): + active_stage = stage + return active_stage + + +def predict_harvest_from_forecasts( + forecasts: list[Any], + plant: Any | None, +) -> HarvestPrediction: + profile = resolve_growth_profile(plant) + base_temperature = float(profile.get("base_temperature", DEFAULT_GROWTH_PROFILE["base_temperature"])) + required_gdd = float(profile.get("required_gdd_for_maturity", DEFAULT_GROWTH_PROFILE["required_gdd_for_maturity"])) + current_cumulative_gdd = float(profile.get("current_cumulative_gdd", DEFAULT_GROWTH_PROFILE["current_cumulative_gdd"])) + + cumulative_gdd = current_cumulative_gdd + daily_forecast: list[dict[str, float | str]] = [] + estimated_date = forecasts[-1].forecast_date if forecasts else date.today() + + for forecast in forecasts: + tmax = float(getattr(forecast, "temperature_max", None) or getattr(forecast, "temperature_mean", 0.0)) + tmin = float(getattr(forecast, "temperature_min", None) or getattr(forecast, "temperature_mean", 0.0)) + daily_gdd = calculate_daily_gdd(tmax=tmax, tmin=tmin, tbase=base_temperature) + cumulative_gdd += daily_gdd + daily_forecast.append( + { + "date": forecast.forecast_date.isoformat(), + "gdd": daily_gdd, + "cumulative_gdd": round(cumulative_gdd, 3), + } + ) + if cumulative_gdd >= required_gdd: + estimated_date = forecast.forecast_date + break + else: + remaining_gdd_after_forecast = max(required_gdd - cumulative_gdd, 0.0) + avg_gdd = sum(item["gdd"] for item in daily_forecast) / len(daily_forecast) if daily_forecast else 0.0 + extra_days = int(remaining_gdd_after_forecast / avg_gdd) + (1 if avg_gdd > 0 and remaining_gdd_after_forecast > 0 else 0) + estimated_date = estimated_date + timedelta(days=max(extra_days, 0)) + + remaining_gdd = max(required_gdd - current_cumulative_gdd, 0.0) + estimated_days = max((estimated_date - date.today()).days, 0) + active_stage = determine_active_stage(current_cumulative_gdd, profile.get("stage_thresholds", {})) + + return HarvestPrediction( + current_cumulative_gdd=round(current_cumulative_gdd, 3), + required_gdd_for_maturity=round(required_gdd, 3), + remaining_gdd=round(remaining_gdd, 3), + estimated_days_to_harvest=estimated_days, + predicted_harvest_date=estimated_date.isoformat(), + predicted_harvest_window={ + "start": (estimated_date - timedelta(days=3)).isoformat(), + "end": (estimated_date + timedelta(days=3)).isoformat(), + }, + daily_gdd_forecast=daily_forecast, + active_stage=active_stage, + ) diff --git a/Modules/Ai/plant/management/__init__.py b/Modules/Ai/plant/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/plant/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/plant/management/commands/__init__.py b/Modules/Ai/plant/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/plant/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/plant/management/commands/seed_plants.py b/Modules/Ai/plant/management/commands/seed_plants.py new file mode 100644 index 0000000..95a6c21 --- /dev/null +++ b/Modules/Ai/plant/management/commands/seed_plants.py @@ -0,0 +1,109 @@ +""" +Management command to seed initial plant data. +Run: python manage.py seed_plants +""" + +from django.core.management.base import BaseCommand + +from plant.models import Plant + + +INITIAL_PLANTS = [ + { + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل (۶-۸ ساعت)", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی، pH بین ۶-۶.۸", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل، کمپوست", + }, + { + "name": "خیار", + "light": "آفتاب کامل", + "watering": "روزانه در فصل گرم", + "soil": "لومی شنی، غنی از هوموس", + "temperature": "۱۸-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار تا اوایل تابستان", + "harvest_time": "۵۰-۷۰ روز پس از کاشت", + "spacing": "۳۰-۴۵ سانتی‌متر", + "fertilizer": "کود ازته، کمپوست", + }, + { + "name": "فلفل دلمه‌ای", + "light": "آفتاب کامل (۶-۸ ساعت)", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، زهکشی مناسب", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۶۰-۹۰ روز پس از کاشت", + "spacing": "۴۰-۵۰ سانتی‌متر", + "fertilizer": "کود فسفره و پتاسه", + }, + { + "name": "هویج", + "light": "آفتاب کامل تا نیمه‌سایه", + "watering": "منظم، خاک مرطوب", + "soil": "شنی لومی، عمیق، بدون سنگ", + "temperature": "۱۵-۲۵ درجه سانتی‌گراد", + "planting_season": "اوایل بهار یا پاییز", + "harvest_time": "۷۰-۸۰ روز پس از کاشت", + "spacing": "۵-۸ سانتی‌متر", + "fertilizer": "کود پتاسه، کمپوست پوسیده", + }, + { + "name": "کاهو", + "light": "نیمه‌سایه تا آفتاب کامل", + "watering": "منظم، خاک مرطوب", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۱۰-۲۰ درجه سانتی‌گراد", + "planting_season": "بهار و پاییز", + "harvest_time": "۴۵-۶۰ روز پس از کاشت", + "spacing": "۲۰-۳۰ سانتی‌متر", + "fertilizer": "کود ازته، کمپوست", + }, + { + "name": "سیب‌زمینی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ بار", + "soil": "لومی شنی، اسیدی ملایم، pH بین ۵-۶", + "temperature": "۱۵-۲۲ درجه سانتی‌گراد", + "planting_season": "اواخر زمستان تا اوایل بهار", + "harvest_time": "۹۰-۱۲۰ روز پس از کاشت", + "spacing": "۳۰-۴۰ سانتی‌متر", + "fertilizer": "کود NPK، کمپوست", + }, + { + "name": "پیاز", + "light": "آفتاب کامل", + "watering": "منظم، خاک مرطوب ولی نه غرقابی", + "soil": "لومی، زهکشی خوب", + "temperature": "۱۲-۲۴ درجه سانتی‌گراد", + "planting_season": "پاییز یا اوایل بهار", + "harvest_time": "۹۰-۱۵۰ روز پس از کاشت", + "spacing": "۱۰-۱۵ سانتی‌متر", + "fertilizer": "کود فسفره، سولفات پتاسیم", + }, +] + + +class Command(BaseCommand): + help = "Seed initial plant data (7 common vegetables)" + + def handle(self, *args, **options): + created_count = 0 + for plant_data in INITIAL_PLANTS: + _, created = Plant.objects.get_or_create( + name=plant_data["name"], + defaults=plant_data, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {plant_data['name']}") + ) + self.stdout.write( + self.style.SUCCESS(f"\nDone. Created {created_count} new plants.") + ) diff --git a/Modules/Ai/plant/migrations/0001_initial.py b/Modules/Ai/plant/migrations/0001_initial.py new file mode 100644 index 0000000..e4d799d --- /dev/null +++ b/Modules/Ai/plant/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='نام گیاه', max_length=255, unique=True)), + ('light', models.CharField(blank=True, help_text='نور مورد نیاز', max_length=255)), + ('watering', models.CharField(blank=True, help_text='آبیاری', max_length=255)), + ('soil', models.CharField(blank=True, help_text='خاک مناسب', max_length=255)), + ('temperature', models.CharField(blank=True, help_text='دمای مناسب', max_length=255)), + ('planting_season', models.CharField(blank=True, help_text='فصل کاشت', max_length=255)), + ('harvest_time', models.CharField(blank=True, help_text='زمان برداشت', max_length=255)), + ('spacing', models.CharField(blank=True, help_text='فاصله کاشت', max_length=255)), + ('fertilizer', models.CharField(blank=True, help_text='کود مناسب', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'گیاه', + 'verbose_name_plural': 'گیاهان', + 'ordering': ['name'], + }, + ), + ] diff --git a/Modules/Ai/plant/migrations/0002_plant_health_profile.py b/Modules/Ai/plant/migrations/0002_plant_health_profile.py new file mode 100644 index 0000000..c896745 --- /dev/null +++ b/Modules/Ai/plant/migrations/0002_plant_health_profile.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="health_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ), + ), + ] diff --git a/Modules/Ai/plant/migrations/0003_plant_irrigation_profile.py b/Modules/Ai/plant/migrations/0003_plant_irrigation_profile.py new file mode 100644 index 0000000..b615274 --- /dev/null +++ b/Modules/Ai/plant/migrations/0003_plant_irrigation_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0002_plant_health_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="irrigation_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + 'نمونه: {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ), + ), + ] diff --git a/Modules/Ai/plant/migrations/0004_plant_growth_profile.py b/Modules/Ai/plant/migrations/0004_plant_growth_profile.py new file mode 100644 index 0000000..96245ff --- /dev/null +++ b/Modules/Ai/plant/migrations/0004_plant_growth_profile.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0003_plant_irrigation_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="growth_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + 'نمونه: {"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ), + ), + ] diff --git a/Modules/Ai/plant/migrations/0005_plant_growth_stage.py b/Modules/Ai/plant/migrations/0005_plant_growth_stage.py new file mode 100644 index 0000000..fa88b23 --- /dev/null +++ b/Modules/Ai/plant/migrations/0005_plant_growth_stage.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0004_plant_growth_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="growth_stage", + field=models.CharField( + blank=True, + help_text="مرحله رشد", + max_length=255, + ), + ), + ] diff --git a/Modules/Ai/plant/migrations/0006_plant_icon.py b/Modules/Ai/plant/migrations/0006_plant_icon.py new file mode 100644 index 0000000..dc526cb --- /dev/null +++ b/Modules/Ai/plant/migrations/0006_plant_icon.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("plant", "0005_plant_growth_stage"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="icon", + field=models.CharField( + blank=True, + default="leaf", + help_text="آیکون گیاه برای نمایش در فرانت", + max_length=255, + ), + ), + ] diff --git a/Modules/Ai/plant/migrations/__init__.py b/Modules/Ai/plant/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/plant/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/plant/models.py b/Modules/Ai/plant/models.py new file mode 100644 index 0000000..1233c3a --- /dev/null +++ b/Modules/Ai/plant/models.py @@ -0,0 +1,101 @@ +from django.db import models + + +class Plant(models.Model): + """ + اطلاعات گیاهان شامل شرایط نگهداری و کاشت. + """ + + name = models.CharField( + max_length=255, + unique=True, + db_index=True, + help_text="نام گیاه", + ) + icon = models.CharField( + max_length=255, + blank=True, + default="leaf", + help_text="آیکون گیاه برای نمایش در فرانت", + ) + light = models.CharField( + max_length=255, + blank=True, + help_text="نور مورد نیاز", + ) + watering = models.CharField( + max_length=255, + blank=True, + help_text="آبیاری", + ) + soil = models.CharField( + max_length=255, + blank=True, + help_text="خاک مناسب", + ) + temperature = models.CharField( + max_length=255, + blank=True, + help_text="دمای مناسب", + ) + growth_stage = models.CharField( + max_length=255, + blank=True, + help_text="مرحله رشد", + ) + planting_season = models.CharField( + max_length=255, + blank=True, + help_text="فصل کاشت", + ) + harvest_time = models.CharField( + max_length=255, + blank=True, + help_text="زمان برداشت", + ) + spacing = models.CharField( + max_length=255, + blank=True, + help_text="فاصله کاشت", + ) + fertilizer = models.CharField( + max_length=255, + blank=True, + help_text="کود مناسب", + ) + health_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل سلامت گیاه برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ) + irrigation_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل آبیاری گیاه برای محاسبات ETc. " + 'نمونه: {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ) + growth_profile = models.JSONField( + default=dict, + blank=True, + help_text=( + "پروفایل رشد گیاه برای مدل GDD. " + 'نمونه: {"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + verbose_name = "گیاه" + verbose_name_plural = "گیاهان" + + def __str__(self): + return self.name diff --git a/Modules/Ai/plant/serializers.py b/Modules/Ai/plant/serializers.py new file mode 100644 index 0000000..37a1822 --- /dev/null +++ b/Modules/Ai/plant/serializers.py @@ -0,0 +1,64 @@ +from rest_framework import serializers + +from .models import Plant + + +DEFAULT_PLANT_GROWTH_STAGES = [ + "initial", + "vegetative", + "flowering", + "fruiting", + "maturity", +] + + +def normalize_growth_stage_values(plant: Plant) -> list[str]: + stages: list[str] = [] + + raw_stage = (plant.growth_stage or "").replace("،", ",") + for part in raw_stage.split(","): + value = part.strip() + if value and value not in stages: + stages.append(value) + + stage_thresholds = plant.growth_profile.get("stage_thresholds", {}) + if isinstance(stage_thresholds, dict): + for stage_name in stage_thresholds.keys(): + value = str(stage_name).strip() + if value and value not in stages: + stages.append(value) + + if not stages: + stages = list(DEFAULT_PLANT_GROWTH_STAGES) + + return stages + + +class PlantSerializer(serializers.ModelSerializer): + """سریالایزر خروجی / ورودی برای Plant.""" + + class Meta: + model = Plant + fields = [ + "id", + "name", + "icon", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + +class PlantNameStageSerializer(serializers.Serializer): + name = serializers.CharField() + icon = serializers.CharField() + growth_stages = serializers.ListField(child=serializers.CharField()) diff --git a/Modules/Ai/plant/services.py b/Modules/Ai/plant/services.py new file mode 100644 index 0000000..4095dae --- /dev/null +++ b/Modules/Ai/plant/services.py @@ -0,0 +1,34 @@ +""" +سرویس‌های گیاه — دریافت مشخصات گیاه از API خارجی بر اساس نام. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def fetch_plant_info_from_api(plant_name: str) -> dict | None: + """ + اتصال به API خارجی و دریافت مشخصات گیاه بر اساس نام. + + TODO: پیاده‌سازی اتصال واقعی به API. + در حال حاضر این تابع خالی است و None برمی‌گرداند. + + پارامترها: + plant_name: نام گیاه + + خروجی مورد انتظار (وقتی پیاده‌سازی شود): + { + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + } + """ + # TODO: اتصال واقعی به API + return None diff --git a/Modules/Ai/plant/urls.py b/Modules/Ai/plant/urls.py new file mode 100644 index 0000000..67a4280 --- /dev/null +++ b/Modules/Ai/plant/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .views import ( + PlantDetailView, + PlantFetchInfoView, + PlantListCreateView, + PlantNameStageListView, +) + +urlpatterns = [ + path("", PlantListCreateView.as_view(), name="plant-list-create"), + path("names/", PlantNameStageListView.as_view(), name="plant-name-stage-list"), + path("/", PlantDetailView.as_view(), name="plant-detail"), + path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"), +] diff --git a/Modules/Ai/plant/views.py b/Modules/Ai/plant/views.py new file mode 100644 index 0000000..ec86d2f --- /dev/null +++ b/Modules/Ai/plant/views.py @@ -0,0 +1,364 @@ +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response +from .models import Plant +from .serializers import ( + PlantNameStageSerializer, + PlantSerializer, + normalize_growth_stage_values, +) +from .services import fetch_plant_info_from_api + + +PlantListResponseSerializer = build_envelope_serializer( + "PlantListResponseSerializer", + PlantSerializer, + many=True, +) +PlantDetailResponseSerializer = build_envelope_serializer( + "PlantDetailResponseSerializer", + PlantSerializer, +) +PlantValidationErrorSerializer = build_envelope_serializer( + "PlantValidationErrorSerializer", + data_required=False, + allow_null=True, +) +PlantFetchInfoResponseSerializer = build_envelope_serializer( + "PlantFetchInfoResponseSerializer", + PlantSerializer, +) +PlantNameStageListResponseSerializer = build_envelope_serializer( + "PlantNameStageListResponseSerializer", + PlantNameStageSerializer, + many=True, +) + + +class PlantListCreateView(APIView): + """لیست تمام گیاهان و ایجاد گیاه جدید.""" + + @extend_schema( + tags=["Plant"], + summary="لیست گیاهان", + description="لیست تمام گیاهان ذخیره‌شده را برمی‌گرداند.", + responses={ + 200: build_response( + PlantListResponseSerializer, + "لیست گیاهان ذخیره‌شده.", + ), + }, + ) + def get(self, request): + plants = Plant.objects.all() + serializer = PlantSerializer(plants, many=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ایجاد گیاه جدید", + description="یک گیاه جدید با مشخصات داده‌شده ایجاد می‌کند.", + request=PlantSerializer, + responses={ + 201: build_response( + PlantDetailResponseSerializer, + "گیاه جدید با موفقیت ایجاد شد.", + ), + 400: build_response( + PlantValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "growth_stage": "رشد رویشی", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = PlantSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 201, "msg": "success", "data": serializer.data}, + status=status.HTTP_201_CREATED, + ) + + +class PlantNameStageListView(APIView): + """لیست سبک از نام گیاه، آیکون و مراحل رشد.""" + + @extend_schema( + tags=["Plant"], + summary="لیست نام گیاهان با مراحل رشد", + description=( + "فقط نام گیاه، آیکون و مراحل رشد را برمی‌گرداند. " + "اگر برای گیاهی مرحله رشد ثبت نشده باشد، مراحل پیش‌فرض به آن اضافه و ذخیره می‌شود." + ), + responses={ + 200: build_response( + PlantNameStageListResponseSerializer, + "لیست نام گیاهان به همراه مراحل رشد و آیکون.", + ), + }, + ) + def get(self, request): + payload = [] + for plant in Plant.objects.all(): + growth_stages = normalize_growth_stage_values(plant) + serialized_stages = ", ".join(growth_stages) + update_fields: list[str] = [] + + if plant.growth_stage != serialized_stages: + plant.growth_stage = serialized_stages + update_fields.append("growth_stage") + if not plant.icon: + plant.icon = "leaf" + update_fields.append("icon") + if update_fields: + update_fields.append("updated_at") + plant.save(update_fields=update_fields) + + payload.append( + { + "name": plant.name, + "icon": plant.icon, + "growth_stages": growth_stages, + } + ) + + serializer = PlantNameStageSerializer(payload, many=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + +class PlantDetailView(APIView): + """دریافت، ویرایش و حذف یک گیاه.""" + + def _get_plant(self, pk): + return Plant.objects.filter(pk=pk).first() + + @extend_schema( + tags=["Plant"], + summary="جزئیات گیاه", + description="مشخصات یک گیاه را بر اساس شناسه برمی‌گرداند.", + responses={ + 200: build_response( + PlantDetailResponseSerializer, + "جزئیات گیاه.", + ), + 404: build_response( + PlantValidationErrorSerializer, + "گیاه یافت نشد.", + ), + }, + ) + def get(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ویرایش کامل گیاه", + description="تمام فیلدهای یک گیاه را آپدیت می‌کند.", + request=PlantSerializer, + responses={ + 200: build_response( + PlantDetailResponseSerializer, + "گیاه با موفقیت به‌روزرسانی شد.", + ), + 400: build_response( + PlantValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + PlantValidationErrorSerializer, + "گیاه یافت نشد.", + ), + }, + ) + def put(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant, data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ویرایش جزئی گیاه", + description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", + request=PlantSerializer, + responses={ + 200: build_response( + PlantDetailResponseSerializer, + "گیاه با موفقیت به‌روزرسانی شد.", + ), + 400: build_response( + PlantValidationErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + PlantValidationErrorSerializer, + "گیاه یافت نشد.", + ), + }, + ) + def patch(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant, data=request.data, partial=True) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="حذف گیاه", + description="یک گیاه را حذف می‌کند.", + responses={ + 200: build_response( + PlantValidationErrorSerializer, + "گیاه با موفقیت حذف شد.", + ), + 404: build_response( + PlantValidationErrorSerializer, + "گیاه یافت نشد.", + ), + }, + ) + def delete(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + plant.delete() + return Response( + {"code": 200, "msg": "گیاه با موفقیت حذف شد.", "data": None}, + status=status.HTTP_200_OK, + ) + + +class PlantFetchInfoView(APIView): + """دریافت مشخصات گیاه از API خارجی بر اساس نام.""" + + @extend_schema( + tags=["Plant"], + summary="دریافت مشخصات گیاه از API خارجی", + description="بر اساس نام گیاه، مشخصات آن را از API خارجی دریافت می‌کند. (فعلاً خالی)", + request=inline_serializer( + name="PlantFetchInfoRequest", + fields={ + "name": drf_serializers.CharField(help_text="نام گیاه"), + }, + ), + responses={ + 200: build_response( + PlantFetchInfoResponseSerializer, + "اطلاعات گیاه از سرویس خارجی دریافت شد.", + ), + 400: build_response( + PlantValidationErrorSerializer, + "نام گیاه ارسال نشده است.", + ), + 503: build_response( + PlantValidationErrorSerializer, + "سرویس خارجی در دسترس نیست.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"name": "گوجه‌فرنگی"}, + request_only=True, + ), + ], + ) + def post(self, request): + plant_name = request.data.get("name") + if not plant_name: + return Response( + {"code": 400, "msg": "نام گیاه الزامی است.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = fetch_plant_info_from_api(plant_name) + if result is None: + return Response( + { + "code": 503, + "msg": "سرویس API هنوز پیاده‌سازی نشده است.", + "data": None, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response( + {"code": 200, "msg": "success", "data": result}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/psce_doc.txt b/Modules/Ai/psce_doc.txt new file mode 100644 index 0000000..5f491f1 --- /dev/null +++ b/Modules/Ai/psce_doc.txt @@ -0,0 +1,8105 @@ +Code documentation +How to read +The API documentation provides a description of the interface and internals of all SimulationObjects, AncillaryObjects and utility routines available in the PCSE source distribution. All SimulationObjects and AncillaryObjects are described using the same structure: + +A short description of the object + +The positional parameters and keywords specified in the interface. + +A table specifying the simulation parameters needed for the simulation + +A table specifying the state variables of the SimulationObject + +A table specifying the rate variables of the SimulationObject + +Signals sent or received by the SimulationObject + +External dependencies on state/rate variables of other SimulationObjects. + +The exceptions that are raised under which conditions. + +One or more of these sections may be excluded when they are not relevant for the SimulationObject that is described. + +The table specifying the simulation parameters has the following columns: + +The name of the parameter. + +A description of the parameter. + +The type of the parameter. This is provided as a three-character code with the following interpretation. The first character indicates of the parameter is a scalar (S) or table (T) parameter. The second and third + +The physical unit of the parameter. + +The tables specifying state/rate variables have the following columns: + +The name of the variable. + +A description of the variable. + +Whether the variable is published in the kiosk or not: Y|N + +The physical unit of the variable. + +Finally, all public methods of all objects are described as well. + +Engine and models +The PCSE Engine provides the environment where SimulationObjects are ‘living’. The engine takes care of reading the model configuration, initializing model components (e.g. groups of SimulationObjects), driving the simulation forward by calling the SimulationObjects, calling the agromanagement unit, keeping track of time and providing the weather data needed. + +Models are treated together with the Engine, because models are simply pre-configured Engines. Any model can be started by starting the Engine with the appropriate configuration file. The only difference is that models can have methods that deal with specific characteristics of a model. This kind of functionality cannot be implemented in the Engine because the model details are not known beforehand. + +classpcse.engine.CGMSEngine(**kwargs)[source] +Engine to mimic CGMS behaviour. + +The original CGMS did not terminate when the crop cycle was finished but instead continued with its simulation cycle but without altering the crop and soil components. This had the effect that after the crop cycle finished, all state variables were kept at the same value while the day counter increased. This behaviour is useful for two reasons: + +CGMS generally produces dekadal output and when a day-of-maturity or day-of-harvest does not coincide with a dekad boundary the final simulation values remain available and are stored at the next dekad. + +When aggregating spatial simulations with variability in day-of-maturity or day-of-harvest it ensures that records are available in the database tables. So GroupBy clauses in SQL queries produce the right results when computing spatial averages. + +The difference with the Engine are: + +Crop rotations are not supported + +After a CROP_FINISH signal, the engine will continue, updating the timer but the soil, crop and agromanagement will not execute their simulation cycles. As a consequence, all state variables will retain their value. + +TERMINATE signals have no effect. + +CROP_FINISH signals will never remove the CROP SimulationObject. + +run() and run_till_terminate() are not supported, only run_till() is supported. + +run(days=1)[source] +Advances the system state with given number of days + +run_till(rday)[source] +Runs the system until rday is reached. + +run_till_terminate()[source] +Runs the system until a terminate signal is sent. + +classpcse.engine.Engine(**kwargs)[source] +Simulation engine for simulating the combined soil/crop system. + +Parameters +: +parameterprovider – A ParameterProvider object providing model parameters as key/value pairs. The parameterprovider encapsulates the different parameter sets for crop, soil and site parameters. + +weatherdataprovider – An instance of a WeatherDataProvider that can return weather data in a WeatherDataContainer for a given date. + +agromanagement – AgroManagement data. The data format is described in the section on agronomic management. + +config – A string describing the model configuration file to use. By only giving a filename PCSE assumes it to be located in the ‘conf/’ folder in the main PCSE folder. If you want to provide you own configuration file, specify it as an absolute or a relative path (e.g. with a leading ‘.’) + +output_vars – the variable names to add/replace for the OUTPUT_VARS configuration variable + +summary_vars – the variable names to add/replace for the SUMMARY_OUTPUT_VARS configuration variable + +terminal_vars – the variable names to add/replace for the TERMINAL_OUTPUT_VARS configuration variable + +Engine handles the actual simulation of the combined soil- crop system. The central part of the Engine is the soil water balance which is continuously simulating during the entire run. In contrast, CropSimulation objects are only initialized after receiving a “CROP_START” signal from the AgroManagement unit. From that point onward, the combined soil-crop is simulated including the interactions between the soil and crop such as root growth and transpiration. + +Similarly, the crop simulation is finalized when receiving a “CROP_FINISH” signal. At that moment the finalize() section on the cropsimulation is executed. Moreover, the “CROP_FINISH” signal can specify that the crop simulation object should be deleted from the hierarchy. The latter is useful for further extensions of PCSE for running crop rotations. + +Finally, the entire simulation is terminated when a “TERMINATE” signal is received. At that point, the finalize() section on the water balance is executed and the simulation stops. + +Signals handled by Engine: + +Engine handles the following signals: +CROP_START: Starts an instance of CropSimulation for simulating crop growth. See the _on_CROP_START handler for details. + +CROP_FINISH: Runs the finalize() section an instance of CropSimulation and optionally deletes the cropsimulation instance. See the _on_CROP_FINISH handler for details. + +TERMINATE: Runs the finalize() section on the waterbalance module and terminates the entire simulation. See the _on_TERMINATE handler for details. + +OUTPUT: Preserves a copy of the value of selected state/rate variables during simulation for later use. See the _on_OUTPUT handler for details. + +SUMMARY_OUTPUT: Preserves a copy of the value of selected state/rate variables for later use. Summary output is usually requested only at the end of the crop simulation. See the _on_SUMMARY_OUTPUT handler for details. + +get_output()[source] +Returns the variables have have been stored during the simulation. + +If no output is stored an empty list is returned. Otherwise, the output is returned as a list of dictionaries in chronological order. Each dictionary is a set of stored model variables for a certain date. + +get_summary_output()[source] +Returns the summary variables have have been stored during the simulation. + +get_terminal_output()[source] +Returns the terminal output variables have have been stored during the simulation. + +run(days=1)[source] +Advances the system state with given number of days + +run_till(rday)[source] +Runs the system until rday is reached. + +run_till_terminate()[source] +Runs the system until a terminate signal is sent. + +set_variable(varname, value)[source] +Sets the value of the specified state or rate variable. + +Parameters +: +varname – Name of the variable to be updated (string). + +value – Value that it should be updated to (float) + +Returns +: +a dict containing the increments of the variables that were updated (new - old). If the call was unsuccessful in finding the class method (see below) it will return an empty dict. + +Note that ‘setting’ a variable (e.g. updating a model state) is much more complex than just getting a variable, because often some other internal variables (checksums, related state variables) must be updated as well. As there is no generic rule to ‘set’ a variable it is up to the model designer to implement the appropriate code to do the update. + +The implementation of set_variable() works as follows. First it will recursively search for a class method on the simulationobjects with the name _set_variable_ (case sensitive). If the method is found, it will be called by providing the value as input. + +So for updating the crop leaf area index (varname ‘LAI’) to value ‘5.0’, the call will be: set_variable(‘LAI’, 5.0). Internally, this call will search for a class method _set_variable_LAI which will be executed with the value ‘5.0’ as input. + +classpcse.models.Alcepas10_PP(**kwargs)[source] +Convenience class for running the ALCEPAS 1.0 onion model for potential production + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.FAO_WRSI10_WLP_CWB(**kwargs)[source] +Convenience class for computing actual crop water use using the Water Requirements Satisfaction Index with a (modified) FAO WRSI approach. + +see pcse.engine.Engine for description of arguments and keywords + +pcse.models.LINGRA_NWLP_FD +alias of Lingra10_NWLP_CWB_CNB + +pcse.models.LINGRA_PP +alias of Lingra10_PP + +pcse.models.LINGRA_WLP_FD +alias of Lingra10_WLP_CWB + +pcse.models.LINTUL3 +alias of Lintul10_NWLP_CWB_CNB + +classpcse.models.Lingra10_NWLP_CWB_CNB(**kwargs)[source] +Convenience class for running the LINGRA grassland model for nitrogen and water-limited production. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Lingra10_PP(**kwargs)[source] +Convenience class for running the LINGRA grassland model for potential production. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Lingra10_WLP_CWB(**kwargs)[source] +Convenience class for running the LINGRA grassland model for water-limited production. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Lintul10_NWLP_CWB_CNB(**kwargs)[source] +The LINTUL model (Light INTerception and UtiLisation) is a simple general crop model, which simulates dry matter production as the result of light interception and utilization with a constant light use efficiency. + +In literature, this model is known as LINTUL3 and simulates crop growth under water-limited and nitrogen-limited conditions + +see pcse.engine.Engine for description of arguments and keywords + +pcse.models.Wofost71_PP +alias of Wofost72_PP + +pcse.models.Wofost71_WLP_FD +alias of Wofost72_WLP_CWB + +classpcse.models.Wofost72_PP(**kwargs)[source] +Convenience class for running WOFOST7.2 Potential Production. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost72_Phenology(**kwargs)[source] +Convenience class for running WOFOST7.2 phenology only. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost72_WLP_CWB(**kwargs)[source] +Convenience class for running WOFOST7.2 water-limited production. + +see pcse.engine.Engine for description of arguments and keywords + +pcse.models.Wofost72_WLP_FD +alias of Wofost72_WLP_CWB + +classpcse.models.Wofost73_PP(**kwargs)[source] +Convenience class for running WOFOST7.3 Potential Production. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost73_WLP_CWB(**kwargs)[source] +Convenience class for running WOFOST7.3 Water=limited Production using the Classic Waterbalance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost73_WLP_MLWB(**kwargs)[source] +Convenience class for running WOFOST7.3 Water=limited Production using the Multi-layer Waterbalance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_NWLP_CWB_CNB(**kwargs)[source] +Convenience class for running WOFOST8.1 nutrient and water-limited production using the classic waterbalance and classic nitrogen balance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_NWLP_MLWB_CNB(**kwargs)[source] +Convenience class for running WOFOST8.1 nutrient and water-limited production using the multi-layer waterbalance and classic nitrogen balance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_NWLP_MLWB_SNOMIN(**kwargs)[source] +Convenience class for running WOFOST8.1 nutrient and water-limited production using the multi-layer waterbalance and the SNOMIN carbon/nitrogen balance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_PP(**kwargs)[source] +Convenience class for running WOFOST8.1 potential production + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_WLP_CWB(**kwargs)[source] +Convenience class for running WOFOST8.1 water-limited production using the classic waterbalance. + +see pcse.engine.Engine for description of arguments and keywords + +classpcse.models.Wofost81_WLP_MLWB(**kwargs)[source] +Convenience class for running WOFOST8.1 water-limited production using the multi-layer waterbalance. + +see pcse.engine.Engine for description of arguments and keywords + +Agromanagement modules +The routines below implement the agromanagement system in PCSE including crop calendars, rotations, state and timed events. For reading agromanagement data from a file or a database structure see the sections on the reading file input and the database tools. + +classpcse.agromanager.AgroManager(**kwargs)[source] +Class for continuous AgroManagement actions including crop rotations and events. + +See also the documentation for the classes CropCalendar, TimedEventDispatcher and StateEventDispatcher. + +The AgroManager takes care of executing agromanagent actions that typically occur on agricultural fields including planting and harvesting of the crop, as well as management actions such as fertilizer application, irrigation, mowing and spraying. + +The agromanagement during the simulation is implemented as a sequence of campaigns. Campaigns start on a prescribed calendar date and finalize when the next campaign starts. The simulation ends either explicitly by provided a trailing empty campaign or by deriving the end date from the crop calendar and timed events in the last campaign. See also the section below on end_date property. + +Each campaign is characterized by zero or one crop calendar, zero or more timed events and zero or more state events. The structure of the data needed as input for AgroManager is most easily understood with the example (in YAML) below. The definition consists of three campaigns, the first starting on 1999-08-01, the second starting on 2000-09-01 and the last campaign starting on 2001-03-01. The first campaign consists of a crop calendar for winter-wheat starting with sowing at the given crop_start_date. During the campaign there are timed events for irrigation at 2000-05-25 and 2000-06-30. Moreover, there are state events for fertilizer application (event_signal: apply_n) given by development stage (DVS) at DVS 0.3, 0.6 and 1.12. + +The second campaign has no crop calendar, timed events or state events. This means that this is a period of bare soil with only the water balance running. The third campaign is for fodder maize sown at 2001-04-15 with two series of timed events (one for irrigation and one for N application) and no state events. The end date of the simulation in this case will be 2001-11-01 (2001-04-15 + 200 days). + +An example of an agromanagement definition file: + +AgroManagement: +- 1999-08-01: + CropCalendar: + crop_name: wheat + variety_name: winter-wheat + crop_start_date: 1999-09-15 + crop_start_type: sowing + crop_end_date: + crop_end_type: maturity + max_duration: 300 + TimedEvents: + - event_signal: irrigate + name: Timed irrigation events + comment: All irrigation amounts in cm + events_table: + - 2000-05-25: {irrigation_amount: 3.0} + - 2000-06-30: {irrigation_amount: 2.5} + StateEvents: + - event_signal: apply_n + event_state: DVS + zero_condition: rising + name: DVS-based N application table + comment: all fertilizer amounts in kg/ha + events_table: + - 0.3: {N_amount : 1, N_recovery: 0.7} + - 0.6: {N_amount: 11, N_recovery: 0.7} + - 1.12: {N_amount: 21, N_recovery: 0.7} +- 2000-09-01: + CropCalendar: + TimedEvents: + StateEvents +- 2001-03-01: + CropCalendar: + crop_name: maize + variety_name: fodder-maize + crop_start_date: 2001-04-15 + crop_start_type: sowing + crop_end_date: + crop_end_type: maturity + max_duration: 200 + TimedEvents: + - event_signal: irrigate + name: Timed irrigation events + comment: All irrigation amounts in cm + events_table: + - 2001-06-01: {irrigation_amount: 2.0} + - 2001-07-21: {irrigation_amount: 5.0} + - 2001-08-18: {irrigation_amount: 3.0} + - 2001-09-19: {irrigation_amount: 2.5} + - event_signal: apply_n + name: Timed N application table + comment: All fertilizer amounts in kg/ha + events_table: + - 2001-05-25: {N_amount : 50, N_recovery: 0.7} + - 2001-07-05: {N_amount : 70, N_recovery: 0.7} + StateEvents: +propertyend_date +Retrieves the end date of the agromanagement sequence, e.g. the last simulation date. + +Returns +: +a date object + +Getting the last simulation date is more complicated because there are two options. + +1. Adding an explicit trailing empty campaign + +The first option is to explicitly define the end date of the simulation by adding a ‘trailing empty campaign’ to the agromanagement definition. An example of an agromanagement definition with a ‘trailing empty campaigns’ (YAML format) is given below. This example will run the simulation until 2001-01-01: + +Version: 1.0 +AgroManagement: +- 1999-08-01: + CropCalendar: + crop_name: winter-wheat + variety_name: winter-wheat + crop_start_date: 1999-09-15 + crop_start_type: sowing + crop_end_date: + crop_end_type: maturity + max_duration: 300 + TimedEvents: + StateEvents: +- 2001-01-01: +Note that in configurations where the last campaign contains a definition for state events, a trailing empty campaign must be provided because the end date cannot be determined. The following campaign definition will therefore lead to an error: + +Version: 1.0 +AgroManagement: +- 2001-01-01: + CropCalendar: + crop_name: maize + variety_name: fodder-maize + crop_start_date: 2001-04-15 + crop_start_type: sowing + crop_end_date: + crop_end_type: maturity + max_duration: 200 + TimedEvents: + StateEvents: + - event_signal: apply_n + event_state: DVS + zero_condition: rising + name: DVS-based N application table + comment: all fertilizer amounts in kg/ha + events_table: + - 0.3: {N_amount : 1, N_recovery: 0.7} + - 0.6: {N_amount: 11, N_recovery: 0.7} + - 1.12: {N_amount: 21, N_recovery: 0.7} +2. Without an explicit trailing campaign + +The second option is that there is no trailing empty campaign and in that case the end date of the simulation is retrieved from the crop calendar and/or the timed events that are scheduled. In the example below, the end date will be 2000-08-05 as this is the harvest date and there are no timed events scheduled after this date: + +Version: 1.0 +AgroManagement: +- 1999-09-01: + CropCalendar: + crop_name: wheat + variety_name: winter-wheat + crop_start_date: 1999-10-01 + crop_start_type: sowing + crop_end_date: 2000-08-05 + crop_end_type: harvest + max_duration: 330 + TimedEvents: + - event_signal: irrigate + name: Timed irrigation events + comment: All irrigation amounts in cm + events_table: + - 2000-05-01: {irrigation_amount: 2, efficiency: 0.7} + - 2000-06-21: {irrigation_amount: 5, efficiency: 0.7} + - 2000-07-18: {irrigation_amount: 3, efficiency: 0.7} + StateEvents: +In the case that there is no harvest date provided and the crop runs till maturity, the end date from the crop calendar will be estimated as the crop_start_date plus the max_duration. + +initialize(kiosk, agromanagement)[source] +Initialize the AgroManager. + +Parameters +: +kiosk – A PCSE variable Kiosk + +agromanagement – the agromanagement definition, see the example above in YAML. + +propertyndays_in_crop_cycle +Returns the number of days of the current cropping cycle. + +Returns zero if no crop cycle is active. + + +propertystart_date +Retrieves the start date of the agromanagement sequence, e.g. the first simulation date + +Returns +: +a date object + +classpcse.agromanager.CropCalendar(**kwargs)[source] +A crop calendar for managing the crop cycle. + +A CropCalendar object is responsible for storing, checking, starting and ending the crop cycle. The crop calendar is initialized by providing the parameters needed for defining the crop cycle. At each time step the instance of CropCalendar is called and at dates defined by its parameters it initiates the appropriate actions: + +sowing/emergence: A crop_start signal is dispatched including the parameters needed to start the new crop simulation object + +maturity/harvest: the crop cycle is ended by dispatching a crop_finish signal with the appropriate parameters. + +Parameters +: +kiosk – The PCSE VariableKiosk instance + +crop_name – String identifying the crop + +variety_name – String identifying the variety + +crop_start_date – Start date of the crop simulation + +crop_start_type – Start type of the crop simulation (‘sowing’, ‘emergence’) + +crop_end_date – End date of the crop simulation + +crop_end_type – End type of the crop simulation (‘harvest’, ‘maturity’, ‘earliest’) + +max_duration – Integer describing the maximum duration of the crop cycle + +Returns +: +A CropCalendar Instance + +get_end_date()[source] +Return the end date of the crop cycle. + +This is either given as the harvest date or calculated as crop_start_date + max_duration + +Returns +: +a date object + +get_start_date()[source] +Returns the start date of the cycle. This is always self.crop_start_date + +Returns +: +the start date + +validate(campaign_start_date, next_campaign_start_date)[source] +Validate the crop calendar internally and against the interval for the agricultural campaign. + +Parameters +: +campaign_start_date – start date of this campaign + +next_campaign_start_date – start date of the next campaign + +classpcse.agromanager.TimedEventsDispatcher(**kwargs)[source] +Takes care handling events that are connected to a date. + +Events are handled by dispatching a signal (taken from the signals module) and providing the relevant parameters with the signal. TimedEvents can be most easily understood when looking at the definition in the agromanagement file. The following section (in YAML) provides the definition of two instances of TimedEventsDispatchers: + +TimedEvents: +- event_signal: irrigate + name: Timed irrigation events + comment: All irrigation amounts in mm + events_table: + - 2000-01-01: {irrigation_amount: 20} + - 2000-01-21: {irrigation_amount: 50} + - 2000-03-18: {irrigation_amount: 30} + - 2000-03-19: {irrigation_amount: 25} +- event_signal: apply_n + name: Timed N application table + comment: All fertilizer amounts in kg/ha + events_table: + - 2000-01-10: {N_amount : 10, N_recovery: 0.7} + - 2000-01-31: {N_amount : 30, N_recovery: 0.7} + - 2000-03-25: {N_amount : 50, N_recovery: 0.7} + - 2000-04-05: {N_amount : 70, N_recovery: 0.7} +Each TimedEventDispatcher is defined by an event_signal, an optional name, an optional comment and the events_table. The events_table is list which provides for each date the parameters that should be dispatched with the given event_signal. + +get_end_date()[source] +Returns the last date for which a timed event is given + +validate(campaign_start_date, next_campaign_start_date)[source] +Validates the timed events given the campaign window + +Parameters +: +campaign_start_date – Start date of the campaign + +next_campaign_start_date – Start date of the next campaign, can be None + +classpcse.agromanager.StateEventsDispatcher(**kwargs)[source] +Takes care handling events that are connected to a model state variable. + +Events are handled by dispatching a signal (taken from the signals module) and providing the relevant parameters with the signal. StateEvents can be most easily understood when looking at the definition in the agromanagement file. The following section (in YAML) provides the definition of two instances of StateEventsDispatchers: + +StateEvents: +- event_signal: apply_n + event_state: DVS + zero_condition: rising + name: DVS-based N application table + comment: all fertilizer amounts in kg/ha + events_table: + - 0.3: {N_amount : 1, N_recovery: 0.7} + - 0.6: {N_amount: 11, N_recovery: 0.7} + - 1.12: {N_amount: 21, N_recovery: 0.7} +- event_signal: irrigate + event_state: SM + zero_condition: falling + name: Soil moisture driven irrigation scheduling + comment: all irrigation amounts in cm of water + events_table: + - 0.15: {irrigation_amount: 20} +Each StateEventDispatcher is defined by an event_signal, an event_state (e.g. the model state that triggers the event) and a zero condition. Moreover, an optional name and an optional comment can be provided. Finally the events_table specifies at which model state values the event occurs. The events_table is a list which provides for each state the parameters that should be dispatched with the given event_signal. + +For finding the time step at which a state event occurs PCSE uses the concept of zero-crossing. This means that a state event is triggered when (model_state - event_state) equals or crosses zero. The zero_condition defines how this crossing should take place. The value of zero_condition can be: + +rising: the event is triggered when (model_state - event_state) goes from a negative value towards +zero or a positive value. + +falling: the event is triggered when (model_state - event_state) goes from a positive value towards +zero or a negative value. + +either: the event is triggered when (model_state - event_state) crosses or reaches zero from any +direction. + +The impact of the zero_condition can be illustrated using the example definitions above. The development stage of the crop (DVS) only increases from 0 at emergence to 2 at maturity. A StateEvent set on the DVS (first example) will therefore logically have a zero_condition ‘rising’ although ‘either’ could be used as well. A DVS-based event will not occur with zero_condition set to ‘falling’ as the value of DVS will not decrease. + +The soil moisture (SM) however can both increase and decrease. A StateEvent for applying irrigation (second example) will therefore be specified with a zero_condition ‘falling’ because the event must be triggered when the soil moisture level reaches or crosses the minimum level specified by the events_table. Note that if we set the zero_condition to ‘either’ the event would probably occur again the next time-step because the irrigation amount increase the soil moisture and (model_state - event_state) crosses zero again but from the other direction. + +The Timer +classpcse.timer.Timer(**kwargs)[source] +This class implements a basic timer for use with the WOFOST crop model. + +This object implements a simple timer that increments the current time with a fixed time-step of one day at each call and returns its value. Moreover, it generates OUTPUT signals in daily, dekadal or monthly time-steps that can be caught in order to store the state of the simulation for later use. + +Initializing the timer: + +timer = Timer(start_date, kiosk, final_date, mconf) +CurrentDate = timer() +Signals sent or handled: + +“OUTPUT”: sent when the condition for generating output is True which depends on the output type and interval. + +initialize(kiosk, start_date, end_date, mconf)[source] +Parameters +: +day – Start date of the simulation + +kiosk – Variable kiosk of the PCSE instance + +end_date – Final date of the simulation. For example, this date represents (START_DATE + MAX_DURATION) for a single cropping season. This date is not the harvest date because signalling harvest is taken care of by the AgroManagement module. + +mconf – A ConfigurationLoader object, the timer needs access to the configuration attributes mconf.OUTPUT_INTERVAL, mconf.OUTPUT_VARS and mconf.OUTPUT_INTERVAL_DAYS + +Soil process modules +Water balance modules +The PCSE distribution provides several waterbalance modules: +WaterbalancePP which is used for simulation under non-water-limited production + +WaterbalanceFD which is used for simulation of water-limited production under conditions of freely draining soils + +The SnowMAUS for simulation the build-up and melting of the snow cover. + +A multi-layer waterbalance implementing simulations for potential conditions, water-limited free drainage conditions. Currently the model does not support the impact of shallow ground water tables but this will implemented in the future. + +classpcse.soil.WaterbalancePP(**kwargs)[source] +Fake waterbalance for simulation under potential production. + +Keeps the soil moisture content at field capacity and only accumulates crop transpiration and soil evaporation rates through the course of the simulation + +classpcse.soil.WaterbalanceFD(**kwargs)[source] +Waterbalance for freely draining soils under water-limited production. + +The purpose of the soil water balance calculations is to estimate the daily value of the soil moisture content. The soil moisture content influences soil moisture uptake and crop transpiration. + +The dynamic calculations are carried out in two sections, one for the calculation of rates of change per timestep (= 1 day) and one for the calculation of summation variables and state variables. The water balance is driven by rainfall, possibly buffered as surface storage, and evapotranspiration. The processes considered are infiltration, soil water retention, percolation (here conceived as downward water flow from rooted zone to second layer), and the loss of water beyond the maximum root zone. + +The textural profile of the soil is conceived as homogeneous. Initially the soil profile consists of two layers, the actually rooted soil and the soil immediately below the rooted zone until the maximum rooting depth is reached by roots(soil and crop dependent). The extension of the root zone from the initial rooting depth to maximum rooting depth is described in Root_Dynamics class. From the moment that the maximum rooting depth is reached the soil profile may be described as a one layer system depending if the roots are able to penetrate the entire profile. If not a non-rooted part remains at the bottom of the profile. + +The class WaterbalanceFD is derived from WATFD.FOR in WOFOST7.1 with the exception that the depth of the soil is now completely determined by the maximum soil depth (RDMSOL) and not by the minimum of soil depth and crop maximum rooting depth (RDMCR). + +Simulation parameters: + +Name + +Description + +Type + +Unit + +SMFCF + +Field capacity of the soil + +SSo + +SM0 + +Porosity of the soil + +SSo + +SMW + +Wilting point of the soil + +SSo + +CRAIRC + +Soil critical air content (waterlogging) + +SSo + +SOPE + +maximum percolation rate root zone + +SSo + + +KSUB + +maximum percolation rate subsoil + +SSo + + +RDMSOL + +Soil rootable depth + +SSo + +cm + +IFUNRN + +Indicates whether non-infiltrating fraction of rain is a function of storm size (1) or not (0) + +SSi + +SSMAX + +Maximum surface storage + +SSi + +cm + +SSI + +Initial surface storage + +SSi + +cm + +WAV + +Initial amount of water in total soil profile + +SSi + +cm + +NOTINF + +Maximum fraction of rain not-infiltrating into the soil + +SSi + +SMLIM + +Initial maximum moisture content in initial rooting depth zone. + +SSi + +State variables: + +Name + +Description + +Pbl + +Unit + +SM + +Volumetric moisture content in root zone + +Y + +SS + +Surface storage (layer of water on surface) + +N + +cm + +SSI + +Initial urface storage + +N + +cm + +W + +Amount of water in root zone + +N + +cm + +WI + +Initial amount of water in the root zone + +N + +cm + +WLOW + +Amount of water in the subsoil (between current rooting depth and maximum rootable depth) + +N + +cm + +WLOWI + +Initial amount of water in the subsoil + +cm + +WWLOW + +Total amount of water in the soil profile WWLOW = WLOW + W + +N + +cm + +WTRAT + +Total water lost as transpiration as calculated by the water balance. This can be different from the CTRAT variable which only counts transpiration for a crop cycle. + +N + +cm + +EVST + +Total evaporation from the soil surface + +N + +cm + +EVWT + +Total evaporation from a water surface + +N + +cm + +TSR + +Total surface runoff + +N + +cm + +RAINT + +Total amount of rainfall (eff + non-eff) + +N + +cm + +WDRT + +Amount of water added to root zone by increase of root growth + +N + +cm + +TOTINF + +Total amount of infiltration + +N + +cm + +TOTIRR + +Total amount of effective irrigation + +N + +cm + +PERCT + +Total amount of water percolating from rooted zone to subsoil + +N + +cm + +LOSST + +Total amount of water lost to deeper soil + +N + +cm + +DSOS + +Days since oxygen stress, accumulates the number of consecutive days of oxygen stress + +Y + +WBALRT + +Checksum for root zone waterbalance. Will be calculated within finalize(), abs(WBALRT) > 0.0001 will raise a WaterBalanceError + +N + +cm + +WBALTT + +Checksum for total waterbalance. Will be calculated within finalize(), abs(WBALTT) > 0.0001 will raise a WaterBalanceError + +N + +cm + +Rate variables: + +External dependencies: + +Name + +Description + +Provided by + +Unit + +TRA + +Crop transpiration rate + +Evapotranspiration + + +EVSMX + +Maximum evaporation rate from a soil surface below the crop canopy + +Evapotranspiration + + +EVWMX + +Maximum evaporation rate from a water surface below the crop canopy + +Evapotranspiration + + +RD + +Rooting depth + +Root_dynamics + +cm + +Exceptions raised: + +A WaterbalanceError is raised when the waterbalance is not closing at the end of the simulation cycle (e.g water has “leaked” away). + +classpcse.soil.WaterBalanceLayered(**kwargs)[source] +This implements a layered water balance to estimate soil water availability for crop growth and water stress. + +The classic free-drainage water-balance had some important limitations such as the inability to take into account differences in soil texture throughout the profile and its impact on soil water flow. Moreover, in the single layer water balance, rainfall or irrigation will become immediately available to the crop. This is incorrect physical behaviour and in many situations it leads to a very quick recovery of the crop after rainfall since all the roots have immediate access to infiltrating water. Therefore, with more detailed soil data becoming available a more realistic soil water balance was deemed necessary to better simulate soil processes and its impact on crop growth. + +The multi-layer water balance represents a compromise between computational complexity, realistic simulation of water content and availability of data to calibrate such models. The model still runs on a daily time step but does implement the concept of downward and upward flow based on the concept of hydraulic head and soil water conductivity. The latter are combined in the so-called Matric Flux Potential. The model computes two types of flow of water in the soil: + +a “dry flow” from the matric flux potentials (e.g. the suction gradient between layers) + +a “wet flow” under the current layer conductivities and downward gravity. + +Clearly, only the dry flow may be negative (=upward). The dry flow accounts for the large gradient in water potential under dry conditions (but neglects gravity). The wet flow takes into account gravity only and will dominate under wet conditions. The maximum of the dry and wet flow is taken as the downward flow, which is then further limited in order the prevent (a) oversaturation and (b) water content to decrease below field capacity. Upward flow is just the dry flow when it is negative. In this case the flow is limited to a certain fraction of what is required to get the layers at equal potential, taking into account, however, the contribution of an upward flow from further down. + +The configuration of the soil layers is variable but is bound to certain limitations: + +The layer thickness cannot be made too small. In practice, the top layer should not be smaller than 10 to 20 cm. Smaller layers would require smaller time steps than one day to simulate realistically, since rain storms will fill up the top layer very quickly leading to surface runoff because the model cannot handle the infiltration of the rainfall in a single timestep (a day). + +The crop maximum rootable depth must coincide with a layer boundary. This is to avoid that roots can directly access water below the rooting depth. Of course such water may become available gradually by upward flow of moisture at some point during the simulation. + +The current python implementation does not yet implement the impact of shallow groundwater but this will be added in future versions of the model. + +For an introduction to the concept of Matric Flux Potential see for example: + +Pinheiro, Everton Alves Rodrigues, et al. “A Matric Flux Potential Approach to Assess Plant Water Availability in Two Climate Zones in Brazil.” Vadose Zone Journal, vol. 17, no. 1, Jan. 2018, pp. 1–10. https://doi.org/10.2136/vzj2016.09.0083. + +Note: the current implementation of the model (April 2024) is rather ‘Fortran-ish’. This has been done on purpose to allow comparisons with the original code in Fortran90. When we are sure that the implementation performs properly, we can refactor this in to a more functional structure instead of the current code which is too long and full of loops. + +Simulation parameters: + +Besides the parameters in the table below, the multi-layer waterbalance requires a SoilProfileDescription which provides the properties of the different soil layers. See the SoilProfile and SoilLayer classes for the details. + +Name + +Description + +Unit + +NOTINF + +Maximum fraction of rain not-infiltrating into the soil + +IFUNRN + +Indicates whether non-infiltrating fraction of SSi rain is a function of storm size (1) or not (0) + +SSI + +Initial surface storage + +cm + +SSMAX + +Maximum surface storage + +cm + +SMLIM + +Maximum soil moisture content of top soil layer + +cm3/cm3 + +WAV + +Initial amount of water in the soil + +cm + +State variables: + +Name + +Description + +Unit + +WTRAT + +Total water lost as transpiration as calculated by the water balance. This can be different from the CTRAT variable which only counts transpiration for a crop cycle. + +cm + +EVST + +Total evaporation from the soil surface + +cm + +EVWT + +Total evaporation from a water surface + +cm + +TSR + +Total surface runoff + +cm + +RAINT + +Total amount of rainfall (eff + non-eff) + +cm + +WDRT + +Amount of water added to root zone by increase of root growth + +cm + +TOTINF + +Total amount of infiltration + +cm + +TOTIRR + +Total amount of effective irrigation + +cm + +SM + +Volumetric moisture content in the different soil layers (array) + +WC + +Water content in the different soil layers (array) + +cm + +W + +Amount of water in root zone + +cm + +WLOW + +Amount of water in the subsoil (between current rooting depth and maximum rootable depth) + +cm + +WWLOW + +Total amount of water in the soil profile (WWLOW = WLOW + W) + +cm + +WBOT + +Water below maximum rootable depth and unavailable for plant growth. + +cm + +WAVUPP + +Plant available water (above wilting point) in the rooted zone. + +cm + +WAVLOW + +Plant available water (above wilting point) in the potential root zone (below current roots) + +cm + +WAVBOT + +Plant available water (above wilting point) in the zone below the maximum rootable depth + +cm + +SS + +Surface storage (layer of water on surface) + +cm + +SM_MEAN + +Mean water content in rooted zone + +cm3/cm3 + +PERCT + +Total amount of water percolating from rooted zone to subsoil + +cm + +LOSST + +Total amount of water lost to deeper soil + +cm + +Rate variables + +Name + +Description + +Unit + +Flow + +Rate of flow from one layer to the next + +cm/day + +RIN + +Rate of infiltration at the surface + +cm/day + +WTRALY + +Rate of transpiration from the different soil layers (array) + +cm/day + +WTRA + +Total crop transpiration rate accumulated over soil layers. + +cm/day + +EVS + +Soil evaporation rate + +cm/day + +EVW + +Open water evaporation rate + +cm/day + +RIRR + +Rate of irrigation + +cm/day + +DWC + +Net change in water amount per layer (array) + +cm/day + +DRAINT + +Change in rainfall accumlation + +cm/day + +DSS + +Change in surface storage + +cm/day + +DTSR + +Rate of surface runoff + +cm/day + +BOTTOMFLOW + +Flow of the bottom of the profile + +cm/day + +classpcse.soil.soil_profile.SoilProfile(parvalues)[source] +A component that represents the soil column as required by the multilayer waterbalance and SNOMIN. + +Parameters +: +parvalues – a ParameterProvider instance that will be used to retrieve the description of the soil profile which is assumed to be available under the key SoilProfileDescription. + +This class is basically a container that stores SoilLayer instances with some additional logic mainly related to root growth, root status and root water extraction. For detailed information on the properties of the soil layers have a look at the description of the SoilLayer class. + +The description of the soil profile can be defined in YAML format with an example of the structure given below. In this case first three soil physical types are defined under the section SoilLayerTypes. Next, these SoilLayerTypes are used to define an actual soil profile using two upper layers of 10 cm of type TopSoil, three layers of 10, 20 and 30 cm of type MidSoil, a lower layer of 45 cm of type SubSoil and finally a SubSoilType with a thickness of 200 cm. Only the top 3 layers contain a certain amaount of organic carbon (FSOMI). + +An example of a data structure for the soil profile: + +SoilLayerTypes: + TopSoil: &TopSoil + SMfromPF: [-1.0, 0.366, + 1.0, 0.338, + 1.3, 0.304, + 1.7, 0.233, + 2.0, 0.179, + 2.3, 0.135, + 2.4, 0.123, + 2.7, 0.094, + 3.0, 0.073, + 3.3, 0.059, + 3.7, 0.046, + 4.0, 0.039, + 4.17, 0.037, + 4.2, 0.036, + 6.0, 0.02] + CONDfromPF: [-1.0, 1.8451, + 1.0, 1.02119, + 1.3, 0.51055, + 1.7, -0.52288, + 2.0, -1.50864, + 2.3, -2.56864, + 2.4, -2.92082, + 2.7, -4.01773, + 3.0, -5.11919, + 3.3, -6.22185, + 3.7, -7.69897, + 4.0, -8.79588, + 4.17, -9.4318, + 4.2, -9.5376, + 6.0, -11.5376] + CRAIRC: 0.090 + CNRatioSOMI: 9.0 + RHOD: 1.406 + Soil_pH: 7.4 + SoilID: TopSoil + MidSoil: &MidSoil + SMfromPF: [-1.0, 0.366, + 1.0, 0.338, + 1.3, 0.304, + 1.7, 0.233, + 2.0, 0.179, + 2.3, 0.135, + 2.4, 0.123, + 2.7, 0.094, + 3.0, 0.073, + 3.3, 0.059, + 3.7, 0.046, + 4.0, 0.039, + 4.17, 0.037, + 4.2, 0.036, + 6.0, 0.02] + CONDfromPF: [-1.0, 1.8451, + 1.0, 1.02119, + 1.3, 0.51055, + 1.7, -0.52288, + 2.0, -1.50864, + 2.3, -2.56864, + 2.4, -2.92082, + 2.7, -4.01773, + 3.0, -5.11919, + 3.3, -6.22185, + 3.7, -7.69897, + 4.0, -8.79588, + 4.17, -9.4318, + 4.2, -9.5376, + 6.0, -11.5376] + CRAIRC: 0.090 + CNRatioSOMI: 9.0 + RHOD: 1.406 + Soil_pH: 7.4 + SoilID: MidSoil_10 + SubSoil: &SubSoil + SMfromPF: [-1.0, 0.366, + 1.0, 0.338, + 1.3, 0.304, + 1.7, 0.233, + 2.0, 0.179, + 2.3, 0.135, + 2.4, 0.123, + 2.7, 0.094, + 3.0, 0.073, + 3.3, 0.059, + 3.7, 0.046, + 4.0, 0.039, + 4.17, 0.037, + 4.2, 0.036, + 6.0, 0.02] + CONDfromPF: [-1.0, 1.8451, + 1.0, 1.02119, + 1.3, 0.51055, + 1.7, -0.52288, + 2.0, -1.50864, + 2.3, -2.56864, + 2.4, -2.92082, + 2.7, -4.01773, + 3.0, -5.11919, + 3.3, -6.22185, + 3.7, -7.69897, + 4.0, -8.79588, + 4.17, -9.4318, + 4.2, -9.5376, + 6.0, -11.5376] + CRAIRC: 0.090 + CNRatioSOMI: 9.0 + RHOD: 1.406 + Soil_pH: 7.4 + SoilID: SubSoil_10 +SoilProfileDescription: + PFWiltingPoint: 4.2 + PFFieldCapacity: 2.0 + SurfaceConductivity: 70.0 # surface conductivity cm / day + SoilLayers: + - <<: *TopSoil + Thickness: 10 + FSOMI: 0.02 + - <<: *TopSoil + Thickness: 10 + FSOMI: 0.02 + - <<: *MidSoil + Thickness: 10 + FSOMI: 0.01 + - <<: *MidSoil + Thickness: 20 + FSOMI: 0.00 + - <<: *MidSoil + Thickness: 30 + FSOMI: 0.00 + - <<: *SubSoil + Thickness: 45 + FSOMI: 0.00 + SubSoilType: + <<: *SubSoil + Thickness: 200 + GroundWater: null +classpcse.soil.soil_profile.SoilLayer(**kwargs)[source] +Contains the intrinsic and derived properties for each soil layers as required by the multilayer waterbalance and SNOMIN. + +Parameters +: +layer – a soil layer definition providing parameter values for this layer, see table below. + +PFFieldcapacity – the pF value for defining Field Capacity + +PFWiltingPoint – the pF value for defining the Wilting Point + +The following properties have to be defined for each layer. + +Name + +Description + +Unit + +CONDfromPF + +Table function of the 10-base logarithm of the unsaturated hydraulic conductivity as a function of pF. + +log10(cm water d-1), - + +SMfromPF + +Table function that describes the soil moisture content as a function of pF + +m3 water m-3 soil, - + +Thickness + +Layer thickness + +cm + +FSOMI + +Initial fraction of soil organic matter in soil + +kg OM kg-1 soil + +CNRatioSOMI + +Initial C:N ratio of soil organic matter + +kg C kg-1 N + +RHOD + +Bulk density of the soil + +g soil cm-3 soil + +Soil_pH + +pH of the soil layer + +CRAIRC + +Critical air content for aeration for root system + +m3 air m-3 soil + +Based on the soil layer definition the following properties are derived from the parameters in the table above. + +Name + +Description + +Unit + +PFfromSM + +Afgen table providing the inverted SMfromPF curve + +m3 water m-3 soil, - + +MFPfromPF + +AfTen table describing the Matric Flux Potential as a function of the hydraulic head (pF). + +cm2 d-1 + +SM0 + +The volumetric soil moisture content at saturation (pF = -1) + +m3 water m-3 soil + +SMW + +The volumetric soil moisture content at wilting point + +m3 water m-3 soil + +SMFCF + +The volumetric soil moisture content at field capacity + +m3 water m-3 soil + +WC0 + +The soil moisture amount (cm) at saturation (pF = -1) + +cm water + +WCW + +The soil moisture amount (cm) at wilting point + +cm water + +WCFC + +The soil moisture amount (cm) at field capacity + +cm water + +CondFC + +Soil hydraulic conductivity at field capacity + +cm water d-1 + +CondK0 + +Soil hydraulic conductivity at saturation + +cm water d-1 + +Finally rooting_status is initialized to None (not yet known at initialization). + +classpcse.soil.SnowMAUS(**kwargs)[source] +Simple snow accumulation model for agrometeorological applications. + +This is an implementation of the SnowMAUS model which describes the accumulation and melt of snow due to precipitation, snowmelt and sublimation. The SnowMAUS model is designed to keep track of the thickness of the layer of water that is present as snow on the surface, e.g. the Snow Water Equivalent Depth (state variable SWEDEPTH [cm]). Conversion of the SWEDEPTH to the actual snow depth (state variable SNOWDEPTH [cm]) is done by dividing the SWEDEPTH with the snow density in [cm_water/cm_snow]. + +Snow density is taken as a fixed value despite the fact that the snow density is known to vary with the type of snowfall, the temperature and the age of the snow pack. However, more complicated algorithms for snow density would not be consistent with the simplicy of SnowMAUS. + +A drawback of the current implementation is that there is no link to the water balance yet. + +Reference: M. Trnka, E. Kocmánková, J. Balek, J. Eitzinger, F. Ruget, H. Formayer, P. Hlavinka, A. Schaumberger, V. Horáková, M. Možný, Z. Žalud, Simple snow cover model for agrometeorological applications, Agricultural and Forest Meteorology, Volume 150, Issues 7–8, 15 July 2010, Pages 1115-1127, ISSN 0168-1923 + +http://dx.doi.org/10.1016/j.agrformet.2010.04.012 + +Simulation parameters: (provide in crop, soil and sitedata dictionary) + +Name + +Description + +Type + +Unit + +TMINACCU1 + +Upper critical minimum temperature for snow accumulation. + +SSi + + +TMINACCU2 + +Lower critical minimum temperature for snow accumulation + +SSi + + +TMINCRIT + +Critical minimum temperature for snow melt + +SSi + + +TMAXCRIT + +Critical maximum temperature for snow melt + +SSi + + +RMELT + +Melting rate per day per degree Celcius above the critical minimum temperature. + +SSi + + +SCTHRESHOLD + +Snow water equivalent above which the sublimation is taken into account. + +SSi + +cm + +SNOWDENSITY + +Density of snow + +SSi + +cm/cm + +SWEDEPTHI + +Initial depth of layer of water present as snow on the soil surface + +SSi + +cm + +State variables: + +Name + +Description + +Pbl + +Unit + +SWEDEPTH + +Depth of layer of water present as snow on the surface + +N + +cm + +SNOWDEPTH + +Depth of snow present on the surface. + +Y + +cm + +Rate variables: + +Name + +Description + +Pbl + +Unit + +RSNOWACCUM + +Rate of snow accumulation + +N + + +RSNOWSUBLIM + +Rate of snow sublimation + +N + + +RSNOWMELT + +Rate of snow melting + +N + + +Nitrogen and Carbon modules +PCSE contains two modules for nitrogen and carbon in the soil: +The simple N_Soil_Dynamics module which only simulates N availability as a pool of available N without any dynamic processes like leach, volatilization, etc. + +The SNOMIN module (Soil Nitrogen module for Mineral and Inorganic Nitrogen) which is a layered soil carbon/nitrogen balance that also requires the layered soil water balance. It includes the full N dynamics in the soil as well as the impact of organic matter and organic amendments (manure) on the availability of nitrogen in the soil. + +classpcse.soil.N_Soil_Dynamics(**kwargs)[source] +A simple module for soil N dynamics. + +This modules represents the soil as a bucket for available N consisting of two components: 1) a native soil supply which consists of an initial amount of N which will become available with a fixed fraction every day and 2) an external supply which is computed as an amount of N supplied and multiplied by a recovery fraction in order to have an effective amount of N that is available for crop growth. + +This module does not simulate any soil physiological processes and is only a book-keeping approach for N availability. On the other hand, it requires no detailed soil parameters. Only an initial soil amount, the fertilizer inputs, a recovery fraction and a background supply. + +Simulation parameters + +Name + +Description + +Type + +Unit + +NSOILBASE + +Base soil supply of N available through mineralisation + +SSi + +kg ha-1 + +NSOILBASE_FR + +Fraction of base soil N that comes available every day + +SSi + +NAVAILI + +Initial N available in the N pool + +SSi + +kg ha-1 + +BG_N_SUPPLY + +Background supply of N through atmospheric deposition. + +SSi + +kg ha-1day-1 + +State variables + +Name + +Description + +Pbl + +Unit + +NSOIL + +total mineral soil N available at start of growth period + +N + +[kg ha-1] + +NAVAIL + +Total mineral N from soil and fertiliser + +Y + +kg ha-1 + +Rate variables + +Signals send or handled + +N_Soil_Dynamics receives the following signals: +APPLY_N: Is received when an external input from N fertilizer is provided. See _on_APPLY_N() for details. + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +TRA + +Actual crop transpiration increase + +Evapotranspiration + + +TRAMX + +Potential crop transpiration increase + +Evapotranspiration + + +RNuptake + +Rate of N uptake by the crop + +NPK_Demand_Uptake + +kg ha-1day-1 + +classpcse.soil.SNOMIN(**kwargs)[source] +SNOMIN (Soil Nitrogen module for Mineral and Inorganic Nitrogen) is a layered soil nitrogen balance. A full mathematical description of the model is given by Berghuijs et al (2024). + +Berghuijs HNC, Silva JV, Reidsma P, De Wit AJW (2024) Expanding the WOFOST crop model to explore options for sustainable nitrogen management: A study for winter wheat in the Netherlands. European Journal of Agronomy 154 ARTN 127099. https://doi.org/10.1016/j.eja.2024.127099 + +Simulation parameters: + +Name + +Description + +Unit + +A0SOM + +Initial age of soil organic material + +y + +CNRatioBio + +C:N ratio of microbial biomass + +kg C kg-1 N + +FASDIS + +Fraction of assimilation to dissimilation + +KDENIT_REF + +Reference first order denitrification rate constant + +d-1 + +KNIT_REF + +Reference first order nitrification rate constant + +d-1 + +KSORP + +Sorption coefficient ammonium (m3 water kg-1 soil) + +m3 soil kg-1 soil + +MRCDIS + +Michaelis Menten constant for response factor denitrification to soil respiration + +kg C m-2 d-1 + +NO3ConcR + +NO3-N concentration in rain water + +mg NO3–N L- water + +NH4ConcR + +NH4-N concentration in rain water + +mg NH4+-N L-1 water + +NO3I + +Initial amount of NO3-N 1 + +kg NO3–N ha-1 + +NH4I + +Initial amount of NH4-N 1 + +kg NH4+-N ha-1) + +WFPS_CRIT + +Critical water filled pore space fraction + +m3 water m-3 pore + +1 This state variable is defined for each soil layer + +State variables + +Name + +Description + +Unit + +AGE + +Appearant age of amendment (d) 1 + +d + +ORGMAT + +Amount of organic matter (kg ORG ha-1) 1 + +kg OM m-2 + +CORG + +Amount of C in organic matter (kg C ha-1) 1 + +kg C m-2 + +NORG + +Amount of N in organic matter (kg N ha-1) 1 + +kg N m-2 + +NH4 + +Amount of NH4-N (kg N ha-1) 2 + +kg NH4-N m-2 + +NO3 + +Amount of NO3-N (kg N ha-1) 2 + +kg NO3-N m-2 + +1 This state variable is defined for each combination of soil layer and amendment +2 This state variable is defined for each soil layer +Rate variables + +Name + +Description + +Unit + +RAGE + +Rate of change of apparent age 2 + +d d-1 + +RAGEAM + +Initial apparent age 2 + +d d-1 + +RAGEAG + +Rate of ageing of amendment 2 + +d d-1 + +RCORG + +Rate of change of organic C 2 + +kg C m-2 d-1 + +RCORGAM + +Rate pf application organic C 2 + +kg C m-2 d-1 + +RCORGDIS + +Dissimilation rate of organic C 2 + +kg C m-2 d-1 + +RNH4 + +Rate of change amount of NH4+-N 1 + +kg NH4+-N m-2 d-1 + +RNH4AM + +Rate of NH4+-N application 1 + +kg NH4+-N m-2 d-1 + +RNH4DEPOS + +Rate of NH4-N deposition 1 + +kg NH4+-N m-2 d-1 + +RNH4IN + +Rate of NH4+-N inflow from adjacent layer 1 + +kg NH4+-N m-2 d-1 + +RNH4MIN + +Net rate of mineralization 1 + +kg NH4+-N m-2 d-1 + +RNH4NITR + +Rate of nitrification 1 + +kg NH4+-N m-2 d-1 + +RNH4OUT + +Rate of NH4+-N outflow to adjacent layer 1 + +kg NH4+-N m-2 d-1 + +RNH4UP + +Rate of NH4+-N root uptake 1 + +kg NH4+-N m-2 d-1 + +RNO3 + +Rate of change amount of NO3–N 1 + +kg NO3–N m-2 d-1 + +RNO3AM + +Rate of NO3–N application 1 + +kg NO3–N m-2 d-1 + +RNO3DENITR + +Rate of denitrification 1 + +kg NO3–N m-2 d-1 + +RNO3DEPOS + +Rate of NO3–N deposition 1 + +kg NO3–N m-2 d-1 + +RNO3IN + +Rate of NH4+-N inflow from adjacent layer 1 + +kg NO3+-N m-2 d-1 + +RNO3NITR + +Rate of nitrification 1 + +kg NO3–N m-2 d-1 + +RNO3OUT + +Rate of NO3–N outflow to adjacent layer 1 + +kg NO3–N m-2 d-1 + +RNO3UP + +Rate of NO3–N root uptake 1 + +kg NO3–N m-2 d-1 + +RNORG + +Rate of change of organic N 2 + +kg N m-2 d-1 + +RNORGAM + +Rate pf application organic N 2 + +kg N m-2 d-1 + +RNORGDIS + +Dissimilation rate of organic matter 2 + +kg N m-2 d-1 + +RORGMAT + +Rate of change of organic material 2 + +kg OM m-2 d-1 + +RORGMATAM + +Rate of application organic matter 2 + +kg OM m-2 d-1 + +RORGMATDIS + +Dissimilation rate of organic matter 2 + +kg OM m-2 d-1 + +1 This state variable is defined for each soil layer +2 This state variable is defined for each combination of soil layer and amendment +Signals send or handled + +SNOMIN receives the following signals: +APPLY_N_SNOMIN: Is received when an external input from N fertilizer is provided. See _on_APPLY_N_SNOMIN() and signals.apply_n_snomin for details. + +Crop simulation processes for WOFOST +Phenology +classpcse.crop.phenology.DVS_Phenology(**kwargs)[source] +Implements the algorithms for phenologic development in WOFOST. + +Phenologic development in WOFOST is expresses using a unitless scale which takes the values 0 at emergence, 1 at Anthesis (flowering) and 2 at maturity. This type of phenological development is mainly representative for cereal crops. All other crops that are simulated with WOFOST are forced into this scheme as well, although this may not be appropriate for all crops. For example, for potatoes development stage 1 represents the start of tuber formation rather than flowering. + +Phenological development is mainly governed by temperature and can be modified by the effects of day length and vernalization during the period before Anthesis. After Anthesis, only temperature influences the development rate. + +Simulation parameters + +Name + +Description + +Type + +Unit + +TSUMEM + +Temperature sum from sowing to emergence + +SCr + + day + +TBASEM + +Base temperature for emergence + +SCr + + +TEFFMX + +Maximum effective temperature for emergence + +SCr + + +TSUM1 + +Temperature sum from emergence to anthesis + +SCr + + day + +TSUM2 + +Temperature sum from anthesis to maturity + +SCr + + day + +IDSL + +Switch for phenological development options temperature only (IDSL=0), including daylength (IDSL=1) and including vernalization (IDSL>=2) + +SCr SCr + +DLO + +Optimal daylength for phenological development + +SCr + +hr + +DLC + +Critical daylength for phenological development + +SCr + +hr + +DVSI + +Initial development stage at emergence. Usually this is zero, but it can be higher for crops that are transplanted (e.g. paddy rice) + +SCr + +DVSEND + +Final development stage + +SCr + +DTSMTB + +Daily increase in temperature sum as a function of daily mean temperature. + +TCr + + +State variables + +Name + +Description + +Pbl + +Unit + +DVS + +Development stage + +Y + +TSUM + +Temperature sum + +N + + day + +TSUME + +Temperature sum for emergence + +N + + day + +DOS + +Day of sowing + +N + +DOE + +Day of emergence + +N + +DOA + +Day of Anthesis + +N + +DOM + +Day of maturity + +N + +DOH + +Day of harvest + +N + +STAGE + +Current phenological stage, can take the folowing values: emerging|vegetative|reproductive|mature + +N + +Rate variables + +Name + +Description + +Pbl + +Unit + +DTSUME + +Increase in temperature sum for emergence + +N + + +DTSUM + +Increase in temperature sum for anthesis or maturity + +N + + +DVR + +Development rate + +Y + +day-1 + +External dependencies: + +None + +Signals sent or handled + +DVS_Phenology sends the crop_finish signal when maturity is reached and the end_type is ‘maturity’ or ‘earliest’. + +classpcse.crop.phenology.Vernalisation(**kwargs)[source] +Modification of phenological development due to vernalisation. + +The vernalization approach here is based on the work of Lenny van Bussel (2011), which in turn is based on Wang and Engel (1998). The basic principle is that winter wheat needs a certain number of days with temperatures within an optimum temperature range to complete its vernalisation requirement. Until the vernalisation requirement is fulfilled, the crop development is delayed. + +The rate of vernalization (VERNR) is defined by the temperature response function VERNRTB. Within the optimal temperature range 1 day is added to the vernalisation state (VERN). The reduction on the phenological development is calculated from the base and saturated vernalisation requirements (VERNBASE and VERNSAT). The reduction factor (VERNFAC) is scaled linearly between VERNBASE and VERNSAT. + +A critical development stage (VERNDVS) is used to stop the effect of vernalisation when this DVS is reached. This is done to improve model stability in order to avoid that Anthesis is never reached due to a somewhat too high VERNSAT. Nevertheless, a warning is written to the log file, if this happens. + +Van Bussel, 2011. From field to globe: Upscaling of crop growth modelling. Wageningen PhD thesis. http://edepot.wur.nl/180295 + +Wang and Engel, 1998. Simulation of phenological development of wheat crops. Agric. Systems 58:1 pp 1-24 + +Simulation parameters (provide in cropdata dictionary) + +Name + +Description + +Type + +Unit + +VERNSAT + +Saturated vernalisation requirements + +SCr + +days + +VERNBASE + +Base vernalisation requirements + +SCr + +days + +VERNRTB + +Rate of vernalisation as a function of daily mean temperature. + +TCr + +VERNDVS + +Critical development stage after which the effect of vernalisation is halted + +SCr + +State variables + +Name + +Description + +Pbl + +Unit + +VERN + +Vernalisation state + +N + +days + +DOV + +Day when vernalisation requirements are fulfilled. + +N + +ISVERNALISED + +Flag indicated that vernalisation requirement has been reached + +Y + +Rate variables + +Name + +Description + +Pbl + +Unit + +VERNR + +Rate of vernalisation + +N + +VERNFAC + +Reduction factor on development rate due to vernalisation effect. + +Y + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Development Stage Used only to determine if the critical development stage for vernalisation (VERNDVS) is reached. + +Phenology + +Partitioning +classpcse.crop.partitioning.DVS_Partitioning(**kwargs)[source] +Class for assimilate partioning based on development stage (DVS). + +DVS_partioning calculates the partitioning of the assimilates to roots, stems, leaves and storage organs using fixed partitioning tables as a function of crop development stage. The available assimilates are first split into below-ground and abovegrond using the values in FRTB. In a second stage they are split into leaves (FLTB), stems (FSTB) and storage organs (FOTB). + +Since the partitioning fractions are derived from the state variable DVS they are regarded state variables as well. + +Simulation parameters (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +FRTB + +Partitioning to roots as a function of development stage. + +TCr + +FSTB + +Partitioning to stems as a function of development stage. + +TCr + +FLTB + +Partitioning to leaves as a function of development stage. + +TCr + +FOTB + +Partitioning to storage organs as a function of development stage. + +TCr + +State variables + +Name + +Description + +Pbl + +Unit + +FR + +Fraction partitioned to roots. + +Y + +FS + +Fraction partitioned to stems. + +Y + +FL + +Fraction partitioned to leaves. + +Y + +FO + +Fraction partitioned to storage orgains + +Y + +Rate variables + +None + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +Exceptions raised + +A PartitioningError is raised if the partitioning coefficients to leaves, stems and storage organs on a given day do not add up to ‘1’. + +CO2 Assimilation +classpcse.crop.assimilation.WOFOST72_Assimilation(**kwargs)[source] +Class implementing a WOFOST/SUCROS style assimilation routine. + +WOFOST calculates the daily gross CO2 assimilation rate of a crop from the absorbed radiation and the photosynthesis-light response curve of individual leaves. This response is dependent on temperature and leaf age. The absorbed radiation is calculated from the total incoming radiation and the leaf area. Daily gross CO2 assimilation is obtained by integrating the assimilation rates over the leaf layers and over the day. + +Simulation parameters + +Name + +Description + +Type + +Unit + +AMAXTB + +Max. leaf CO2 assim. rate as a function of of DVS + +TCr + +kg ha-1hr-1 + +EFFTB + +Light use effic. single leaf as a function of daily mean temperature + +TCr + +kg ha-1hr-1/(J m-2sec-1) + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS + +TCr + +TMPFTB + +Reduction factor of AMAX as function of daily mean temperature. + +TCr + +TMNFTB + +Reduction factor of AMAX as function of daily minimum temperature. + +TCr + +State and rate variables + +WOFOST_Assimilation returns the potential gross assimilation rate ‘PGASS’ directly from the __call__() method, but also includes it as a rate variable. + +Rate variables: + +Name + +Description + +Pbl + +Unit + +PGASS + +Potential assimilation rate + +N + +kg CH2O ha-1day-1 + +Signals sent or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +Leaf_dynamics + +classpcse.crop.assimilation.WOFOST73_Assimilation(**kwargs)[source] +Class implementing a WOFOST/SUCROS style assimilation routine including effect of changes in atmospheric CO2 concentration. + +WOFOST calculates the daily gross CO2 assimilation rate of a crop from the absorbed radiation and the photosynthesis-light response curve of individual leaves. This response is dependent on temperature and leaf age. The absorbed radiation is calculated from the total incoming radiation and the leaf area. Daily gross CO2 assimilation is obtained by integrating the assimilation rates over the leaf layers and over the day. + +The impact of atmospheric CO2 is implemented by applying empirical adjustments to the AMAX at every development stage (parameter CO2AMAXTB) and EFF (parameter CO2EFFTB). The latter two are defined as a function of atmospheric CO2 concentration. + +Simulation parameters (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +AMAXTB + +Max. leaf CO2 assim. rate as a function of of DVS + +TCr + +kg ha-1hr-1 + +EFFTB + +Light use effic. single leaf as a function of daily mean temperature + +TCr + +kg ha-1hr-1/(J m-2sec-1) + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS + +TCr + +TMPFTB + +Reduction factor of AMAX as function of daily mean temperature. + +TCr + +TMPFTB + +Reduction factor of AMAX as function of daily minimum temperature. + +TCr + +CO2AMAXTB + +Correction factor for AMAX given atmos- pheric CO2 concentration. + +TCr + +CO2EFFTB + +Correction factor for EFF given atmos- pheric CO2 concentration. + +TCr + +CO2 + +Atmopheric CO2 concentration + +SCr + +ppm + +State and rate variables + +WOFOST_Assimilation2 has no state/rate variables, but calculates the rate of assimilation which is returned directly from the __call__() method. + +Signals sent or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +Leaf_dynamics + +classpcse.crop.assimilation.WOFOST81_Assimilation(**kwargs)[source] +Class implementing a WOFOST/SUCROS style assimilation routine including effect of changes in atmospheric CO2 concentration and impact of leaf nitrogen content on the maximum assimilation rate. + +WOFOST calculates the daily gross CO2 assimilation rate of a crop from the absorbed radiation and the photosynthesis-light response curve of individual leaves. This response is dependent on temperature and leaf age. The absorbed radiation is calculated from the total incoming radiation and the leaf area. Daily gross CO2 assimilation is obtained by integrating the assimilation rates over the leaf layers and over the day. + +Simulation parameters (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +AMAX_LNB + +Specific leaf nitrogen below which there is no gross photosynthesis + +Cr + +kg ha-1 + +AMAX_REF + +Maximum leaf CO2 assim. rate under reference conditions and high specific leaf nitrogen. + +TCr + +kg ha-1hr-1 + +AMAX_SLP + +Slope of linear response of AMAX to specific leaf nitrogen content at reference conditions + +Cr Cr + +|kg hr-1 kg-1| + +KN + +Extinction coefficient of N in the canopy + +Cr + +EFFTB + +Light use effic. single leaf as a function of daily mean temperature + +TCr + +kg ha-1hr-1/(J m-2sec-1) + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS + +TCr + +TMPFTB + +Reduction factor of AMAX as function of daily mean temperature. + +TCr + +TMPFTB + +Reduction factor of AMAX as function of daily minimum temperature. + +TCr + +CO2AMAXTB + +Correction factor for AMAX given atmos- pheric CO2 concentration. + +TCr + +CO2EFFTB + +Correction factor for EFF given atmos- pheric CO2 concentration. + +TCr + +CO2 + +Atmopheric CO2 concentration + +SCr + +ppm + +State and rate variables + +WOFOST_Assimilation has no state/rate variables, but calculates the rate of assimilation which is returned directly from the __call__() method. + +Signals sent or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +leaf_dynamics + +NLV + +Leaf nitrogen amount + +n_dynamics + +kg ha-1 + +Maintenance respiration +classpcse.crop.respiration.WOFOST_Maintenance_Respiration(**kwargs)[source] +Maintenance respiration in WOFOST + +WOFOST calculates the maintenance respiration as proportional to the dry weights of the plant organs to be maintained, where each plant organ can be assigned a different maintenance coefficient. Multiplying organ weight with the maintenance coeffients yields the relative maintenance respiration (RMRES) which is than corrected for senescence (parameter RFSETB). Finally, the actual maintenance respiration rate is calculated using the daily mean temperature, assuming a relative increase for each 10 degrees increase in temperature as defined by Q10. + +Simulation parameters: (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +Q10 + +Relative increase in maintenance repiration rate with each 10 degrees increase in temperature + +SCr + +RMR + +Relative maintenance respiration rate for roots + +SCr + +kg CH2O kg-1d-1 + +RMS + +Relative maintenance respiration rate for stems + +SCr + +kg CH2O kg-1d-1 + +RML + +Relative maintenance respiration rate for leaves + +SCr + +kg CH2O kg-1d-1 + +RMO + +Relative maintenance respiration rate for storage organs + +SCr + +kg CH2O kg-1d-1 + +State and rate variables: + +WOFOSTMaintenanceRespiration returns the potential maintenance respiration PMRES +directly from the __call__() method, but also includes it as a rate variable within the object. + +Rate variables: + +Name + +Description + +Pbl + +Unit + +PMRES + +Potential maintenance respiration rate + +N + +kg CH2O ha-1day-1 + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +WRT + +Dry weight of living roots + +WOFOST_Root_Dynamics + +kg ha-1 + +WST + +Dry weight of living stems + +WOFOST_Stem_Dynamics + +kg ha-1 + +WLV + +Dry weight of living leaves + +WOFOST_Leaf_Dynamics + +kg ha-1 + +WSO + +Dry weight of living storage organs + +WOFOST_Storage_Organ_Dynamics + +kg ha-1 + +Evapotranspiration +classpcse.crop.evapotranspiration.Evapotranspiration(**kwargs)[source] +Calculation of potential evaporation (water and soil) rates and actual crop transpiration rate. + +Simulation parameters: + +Name + +Description + +Type + +Unit + +CFET + +Correction factor for potential transpiration rate. + +SCr + +DEPNR + +Dependency number for crop sensitivity to soil moisture stress. + +SCr + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS. + +TCr + +IOX + +Switch oxygen stress on (1) or off (0) + +SCr + +IAIRDU + +Switch airducts on (1) or off (0) + +SCr + +CRAIRC + +Critical air content for root aeration + +SSo + +SM0 + +Soil porosity + +SSo + +SMW + +Volumetric soil moisture content at wilting point + +SSo + +SMCFC + +Volumetric soil moisture content at field capacity + +SSo + +SM0 + +Soil porosity + +SSo + +State variables + +Note that these state variables are only assigned after finalize() has been run. + +Name + +Description + +Pbl + +Unit + +IDWST + +Nr of days with water stress. + +N + +IDOST + +Nr of days with oxygen stress. + +N + +Rate variables + +Name + +Description + +Pbl + +Unit + +EVWMX + +Maximum evaporation rate from an open water surface. + +Y + +cm day-1 + +EVSMX + +Maximum evaporation rate from a wet soil surface. + +Y + +cm day-1 + +TRAMX + +Maximum transpiration rate from the plant canopy + +Y + +cm day-1 + +TRA + +Actual transpiration rate from the plant canopy + +Y + +cm day-1 + +IDOS + +Indicates oxygen stress on this day (True|False) + +N + +IDWS + +Indicates water stress on this day (True|False) + +N + +RFWS + +Reduction factor for water stress + +N + +RFOS + +Reduction factor for oxygen stress + +N + +RFTRA + +Reduction factor for transpiration (wat & ox) + +Y + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +Leaf_dynamics + +SM + +Volumetric soil moisture content + +Waterbalance + +classpcse.crop.evapotranspiration.EvapotranspirationCO2(**kwargs)[source] +Calculation of evaporation (water and soil) and transpiration rates taking into account the CO2 effect on crop transpiration. + +Simulation parameters (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +CFET + +Correction factor for potential transpiration rate. + +S + +DEPNR + +Dependency number for crop sensitivity to soil moisture stress. + +S + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS. + +T + +IOX + +Switch oxygen stress on (1) or off (0) + +S + +IAIRDU + +Switch airducts on (1) or off (0) + +S + +CRAIRC + +Critical air content for root aeration + +S + +SM0 + +Soil porosity + +S + +SMW + +Volumetric soil moisture content at wilting point + +S + +SMCFC + +Volumetric soil moisture content at field capacity + +S + +SM0 + +Soil porosity + +S + +CO2 + +Atmospheric CO2 concentration + +S + +ppm + +CO2TRATB + +Reduction factor for TRAMX as function of atmospheric CO2 concentration + +T + +State variables + +Note that these state variables are only assigned after finalize() has been run. + +Name + +Description + +Pbl + +Unit + +IDWST + +Nr of days with water stress. + +N + +IDOST + +Nr of days with oxygen stress. + +N + +Rate variables + +Name + +Description + +Pbl + +Unit + +EVWMX + +Maximum evaporation rate from an open water surface. + +Y + +cm day-1 + +EVSMX + +Maximum evaporation rate from a wet soil surface. + +Y + +cm day-1 + +TRAMX + +Maximum transpiration rate from the plant canopy + +Y + +cm day-1 + +TRA + +Actual transpiration rate from the plant canopy + +Y + +cm day-1 + +IDOS + +Indicates water stress on this day (True|False) + +N + +IDWS + +Indicates oxygen stress on this day (True|False) + +N + +RFWS + +Reducation factor for water stress + +Y + +RFOS + +Reducation factor for oxygen stress + +Y + +RFTRA + +Reduction factor for transpiration (wat & ox) + +Y + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +Leaf_dynamics + +SM + +Volumetric soil moisture content + +Waterbalance + +classpcse.crop.evapotranspiration.EvapotranspirationCO2Layered(**kwargs)[source] +Calculation of evaporation (water and soil) and transpiration rates taking into account the CO2 effect on crop transpiration for a layered soil + +Simulation parameters (To be provided in cropdata dictionary): + +Name + +Description + +Type + +Unit + +CFET + +Correction factor for potential transpiration rate. + +S + +DEPNR + +Dependency number for crop sensitivity to soil moisture stress. + +S + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS. + +T + +IOX + +Switch oxygen stress on (1) or off (0) + +S + +IAIRDU + +Switch airducts on (1) or off (0) + +S + +CRAIRC + +Critical air content for root aeration + +S + +SM0 + +Soil porosity + +S + +SMW + +Volumetric soil moisture content at wilting point + +S + +SMCFC + +Volumetric soil moisture content at field capacity + +S + +SM0 + +Soil porosity + +S + +CO2 + +Atmospheric CO2 concentration + +S + +ppm + +CO2TRATB + +Reduction factor for TRAMX as function of atmospheric CO2 concentration + +T + +State variables + +Note that these state variables are only assigned after finalize() has been run. + +Name + +Description + +Pbl + +Unit + +IDWST + +Nr of days with water stress. + +N + +IDOST + +Nr of days with oxygen stress. + +N + +Rate variables + +Name + +Description + +Pbl + +Unit + +EVWMX + +Maximum evaporation rate from an open water surface. + +Y + +cm day-1 + +EVSMX + +Maximum evaporation rate from a wet soil surface. + +Y + +cm day-1 + +TRAMX + +Maximum transpiration rate from the plant canopy + +Y + +cm day-1 + +TRA + +Actual transpiration rate from the plant canopy + +Y + +cm day-1 + +IDOS + +Indicates water stress on this day (True|False) + +N + +IDWS + +Indicates oxygen stress on this day (True|False) + +N + +RFWS + +Reducation factor for water stress + +Y + +RFOS + +Reducation factor for oxygen stress + +Y + +RFTRA + +Reduction factor for transpiration (wat & ox) + +Y + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +LAI + +Leaf area index + +Leaf_dynamics + +SM + +Volumetric soil moisture content + +Waterbalance + +pcse.crop.evapotranspiration.SWEAF(ET0, DEPNR)[source] +Calculates the Soil Water Easily Available Fraction (SWEAF). + +Parameters +: +ET0 – The evapotranpiration from a reference crop. + +DEPNR – The crop dependency number. + +The fraction of easily available soil water between field capacity and wilting point is a function of the potential evapotranspiration rate (for a closed canopy) in cm/day, ET0, and the crop group number, DEPNR (from 1 (=drought-sensitive) to 5 (=drought-resistent)). The function SWEAF describes this relationship given in tabular form by Doorenbos & Kassam (1979) and by Van Keulen & Wolf (1986; p.108, table 20) http://edepot.wur.nl/168025. + +Leaf dynamics +classpcse.crop.leaf_dynamics.WOFOST_Leaf_Dynamics(**kwargs)[source] +Leaf dynamics for the WOFOST crop model. + +Implementation of biomass partitioning to leaves, growth and senenscence of leaves. WOFOST keeps track of the biomass that has been partitioned to the leaves for each day (variable LV), which is called a leaf class). For each leaf class the leaf age (variable ‘LVAGE’) and specific leaf area (variable SLA) are also registered. Total living leaf biomass is calculated by summing the biomass values for all leaf classes. Similarly, leaf area is calculated by summing leaf biomass times specific leaf area (LV * SLA). + +Senescense of the leaves can occur as a result of physiological age, drought stress or self-shading. + +Simulation parameters (provide in cropdata dictionary) + +Name + +Description + +Type + +Unit + +RGRLAI + +Maximum relative increase in LAI. + +SCr + +ha ha-1 d-1 + +SPAN + +Life span of leaves growing at 35 Celsius + +SCr + +day + +TBASE + +Lower threshold temp. for ageing of leaves + +SCr + + +PERDL + +Max. relative death rate of leaves due to water stress + +SCr + +TDWI + +Initial total crop dry weight + +SCr + +kg ha-1 + +KDIFTB + +Extinction coefficient for diffuse visible light as function of DVS + +TCr + +SLATB + +Specific leaf area as a function of DVS + +TCr + +ha kg-1 + +State variables + +Name + +Description + +Pbl + +Unit + +LV + +Leaf biomass per leaf class + +N + +kg ha-1 + +SLA + +Specific leaf area per leaf class + +N + +ha kg-1 + +LVAGE + +Leaf age per leaf class + +N + +day + +LVSUM + +Sum of LV + +N + +kg ha-1 + +LAIEM + +LAI at emergence + +N + +LASUM + +Total leaf area as sum of LV*SLA, not including stem and pod area + +N N + +LAIEXP + +LAI value under theoretical exponential growth + +N + +LAIMAX + +Maximum LAI reached during growth cycle + +N + +LAI + +Leaf area index, including stem and pod area + +Y + +WLV + +Dry weight of living leaves + +Y + +kg ha-1 + +DWLV + +Dry weight of dead leaves + +N + +kg ha-1 + +TWLV + +Dry weight of total leaves (living + dead) + +Y + +kg ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +GRLV + +Growth rate leaves + +N + +kg ha-1day-1 + +DSLV1 + +Death rate leaves due to water stress + +N + +kg ha-1day-1 + +DSLV2 + +Death rate leaves due to self-shading + +N + +kg ha-1day-1 + +DSLV3 + +Death rate leaves due to frost kill + +N + +kg ha-1day-1 + +DSLV + +Maximum of DLSV1, DSLV2, DSLV3 + +N + +kg ha-1day-1 + +DALV + +Death rate leaves due to aging. + +N + +kg ha-1day-1 + +DRLV + +Death rate leaves as a combination of DSLV and DALV + +N + +kg ha-1day-1 + +SLAT + +Specific leaf area for current time step, adjusted for source/sink limited leaf expansion rate. + +N + +ha kg-1 + +FYSAGE + +Increase in physiological leaf age + +N + +GLAIEX + +Sink-limited leaf expansion rate (exponential curve) + +N + +ha ha-1day-1 + +GLASOL + +Source-limited leaf expansion rate (biomass increase) + +N + +ha ha-1day-1 + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +FL + +Fraction biomass to leaves + +DVS_Partitioning + +FR + +Fraction biomass to roots + +DVS_Partitioning + +SAI + +Stem area index + +WOFOST_Stem_Dynamics + +PAI + +Pod area index + +WOFOST_Storage_Organ_Dynamics + +TRA + +Transpiration rate + +Evapotranspiration + +cm day-1 + +TRAMX + +Maximum transpiration rate + +Evapotranspiration + +cm day-1 + +ADMI + +Above-ground dry matter increase + +CropSimulation + +kg ha-1day-1 + +RF_FROST + +Reduction factor frost kill + +FROSTOL + +classpcse.crop.leaf_dynamics.WOFOST_Leaf_Dynamics_N(**kwargs)[source] +Leaf dynamics for the WOFOST crop model including leaf response to N stress. + +# HB 20220405: This function was changed quite a bit and needs redocumentation. + +Implementation of biomass partitioning to leaves, growth and senenscence of leaves. WOFOST keeps track of the biomass that has been partitioned to the leaves for each day (variable LV), which is called a leaf class). For each leaf class the leaf age (variable ‘LVAGE’) and specific leaf area are (variable SLA) are also registered. Total living leaf biomass is calculated by summing the biomass values for all leaf classes. Similarly, leaf area is calculated by summing leaf biomass times specific leaf area (LV * SLA). + +Senescense of the leaves can occur as a result of physiological age, drought stress, nutrient stress or self-shading. + +Finally, leaf expansion (SLA) can be influenced by nutrient stress. + +Simulation parameters (provide in cropdata dictionary) + +Name + +Description + +Type + +Unit + +RGRLAI + +Maximum relative increase in LAI. + +SCr + +ha ha-1 d-1 + +SPAN + +Life span of leaves growing at 35 Celsius + +SCr + +day + +TBASE + +Lower threshold temp. for ageing of leaves + +SCr + + +PERDL + +Max. relative death rate of leaves due to water stress + +SCr + +TDWI + +Initial total crop dry weight + +SCr + +kg ha-1 + +KDIFTB + +Extinction coefficient for diffuse visible light as function of DVS + +TCr + +SLATB + +Specific leaf area as a function of DVS + +TCr + +ha kg-1 + +RDRNS + +max. relative death rate of leaves due to nutrient N stress + +TCr + +NLAI + +coefficient for the reduction due to nutrient N stress of the LAI increase (during juvenile phase). + +TCr + +NSLA + +Coefficient for the effect of nutrient NPK stress on SLA reduction + +TCr + +RDRNS + +Max. relative death rate of leaves due to nutrient N stress + +TCr + +State variables + +Name + +Description + +Pbl + +Unit + +LV + +Leaf biomass per leaf class + +N + +kg ha-1 + +SLA + +Specific leaf area per leaf class + +N + +ha kg-1 + +LVAGE + +Leaf age per leaf class + +N + +day + +LVSUM + +Sum of LV + +N + +kg ha-1 + +LAIEM + +LAI at emergence + +N + +LASUM + +Total leaf area as sum of LV*SLA, not including stem and pod area + +N N + +LAIEXP + +LAI value under theoretical exponential growth + +N + +LAIMAX + +Maximum LAI reached during growth cycle + +N + +LAI + +Leaf area index, including stem and pod area + +Y + +WLV + +Dry weight of living leaves + +Y + +kg ha-1 + +DWLV + +Dry weight of dead leaves + +N + +kg ha-1 + +TWLV + +Dry weight of total leaves (living + dead) + +Y + +kg ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +GRLV + +Growth rate leaves + +N + +kg ha-1day-1 + +DSLV1 + +Death rate leaves due to water stress + +N + +kg ha-1day-1 + +DSLV2 + +Death rate leaves due to self-shading + +N + +kg ha-1day-1 + +DSLV3 + +Death rate leaves due to frost kill + +N + +kg ha-1day-1 + +DSLV4 + +Death rate leaves due to nutrient stress + +N + +kg ha-1day-1 + +DSLV + +Maximum of DLSV1, DSLV2, DSLV3 + +N + +kg ha-1day-1 + +DALV + +Death rate leaves due to aging. + +N + +kg ha-1day-1 + +DRLV + +Death rate leaves as a combination of DSLV and DALV + +N + +kg ha-1day-1 + +SLAT + +Specific leaf area for current time step, adjusted for source/sink limited leaf expansion rate. + +N + +ha kg-1 + +FYSAGE + +Increase in physiological leaf age + +N + +GLAIEX + +Sink-limited leaf expansion rate (exponential curve) + +N + +ha ha-1day-1 + +GLASOL + +Source-limited leaf expansion rate (biomass increase) + +N + +ha ha-1day-1 + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +FL + +Fraction biomass to leaves + +DVS_Partitioning + +FR + +Fraction biomass to roots + +DVS_Partitioning + +SAI + +Stem area index + +WOFOST_Stem_Dynamics + +PAI + +Pod area index + +WOFOST_Storage_Organ_Dynamics + +TRA + +Transpiration rate + +Evapotranspiration + +cm day-1 + +TRAMX + +Maximum transpiration rate + +Evapotranspiration + +cm day-1 + +ADMI + +Above-ground dry matter increase + +CropSimulation + +kg ha-1day-1 + +RF_FROST + +Reduction factor frost kill + +FROSTOL + +classpcse.crop.leaf_dynamics.CSDM_Leaf_Dynamics(**kwargs)[source] +Leaf dynamics according to the Canopy Structure Dynamic Model. + +The only difference is that in the real CSDM the temperature sum is the driving variable, while in this case it is simply the day number since the start of the model. + +Reference: Koetz et al. 2005. Use of coupled canopy structure dynamic and radiative transfer models to estimate biophysical canopy characteristics. Remote Sensing of Environment. Volume 95, Issue 1, 15 March 2005, Pages 115-124. http://dx.doi.org/10.1016/j.rse.2004.11.017 + +For plotting the CSDM model with GNUPLOT the following example code can be used: + +td = 150 CSDM_MAX = 5. CSDM_MIN = 0.15 CSDM_A = 0.085 CSDM_B = 0.045 CSDM_T1 = int(td/3.) CSDM_T2 = td + +set xrange [0:200] set yrange [-1:8] plot CSDM_MIN + CSDM_MAX*(1./(1. + exp(-CSDM_B*(x - CSDM_T1)))**2 - exp(CSDM_A*(x - CSDM_T2))) + +Root dynamics +classpcse.crop.root_dynamics.WOFOST_Root_Dynamics(**kwargs)[source] +Root biomass dynamics and rooting depth. + +Root growth and root biomass dynamics in WOFOST are separate processes, with the only exception that root growth stops when no more biomass is sent to the root system. + +Root biomass increase results from the assimilates partitioned to the root system. Root death is defined as the current root biomass multiplied by a relative death rate (RDRRTB). The latter as a function of the development stage (DVS). + +Increase in root depth is a simple linear expansion over time until the maximum rooting depth (RDM) is reached. + +Simulation parameters + +Name + +Description + +Type + +Unit + +RDI + +Initial rooting depth + +SCr + +cm + +RRI + +Daily increase in rooting depth + +SCr + +cm day-1 + +RDMCR + +Maximum rooting depth of the crop + +SCR + +cm + +RDMSOL + +Maximum rooting depth of the soil + +SSo + +cm + +TDWI + +Initial total crop dry weight + +SCr + +kg ha-1 + +IAIRDU + +Presence of air ducts in the root (1) or not (0) + +SCr + +RDRRTB + +Relative death rate of roots as a function of development stage + +TCr + +State variables + +Name + +Description + +Pbl + +Unit + +RD + +Current rooting depth + +Y + +cm + +RDM + +Maximum attainable rooting depth at the minimum of the soil and crop maximum rooting depth + +N + +cm + +WRT + +Weight of living roots + +Y + +kg ha-1 + +DWRT + +Weight of dead roots + +N + +kg ha-1 + +TWRT + +Total weight of roots + +Y + +kg ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +RR + +Growth rate root depth + +N + +cm + +GRRT + +Growth rate root biomass + +N + +kg ha-1day-1 + +DRRT + +Death rate root biomass + +N + +kg ha-1day-1 + +GWRT + +Net change in root biomass + +N + +kg ha-1day-1 + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +DMI + +Total dry matter increase + +CropSimulation + +kg ha-1day-1 + +FR + +Fraction biomass to roots + +DVS_Partitioning + +Stem dynamics +classpcse.crop.stem_dynamics.WOFOST_Stem_Dynamics(**kwargs)[source] +Implementation of stem biomass dynamics. + +Stem biomass increase results from the assimilates partitioned to the stem system. Stem death is defined as the current stem biomass multiplied by a relative death rate (RDRSTB). The latter as a function of the development stage (DVS). + +Stems are green elements of the plant canopy and can as such contribute to the total photosynthetic active area. This is expressed as the Stem Area Index which is obtained by multiplying stem biomass with the Specific Stem Area (SSATB), which is a function of DVS. + +Simulation parameters: + +Name + +Description + +Type + +Unit + +TDWI + +Initial total crop dry weight + +SCr + +kg ha-1 + +RDRSTB + +Relative death rate of stems as a function of development stage + +TCr + +SSATB + +Specific Stem Area as a function of development stage + +TCr + +ha kg-1 + +State variables + +Name + +Description + +Pbl + +Unit + +SAI + +Stem Area Index + +Y + +WST + +Weight of living stems + +Y + +kg ha-1 + +DWST + +Weight of dead stems + +N + +kg ha-1 + +TWST + +Total weight of stems + +Y + +kg ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +GRST + +Growth rate stem biomass + +N + +kg ha-1day-1 + +DRST + +Death rate stem biomass + +N + +kg ha-1day-1 + +GWST + +Net change in stem biomass + +N + +kg ha-1day-1 + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +ADMI + +Above-ground dry matter increase + +CropSimulation + +kg ha-1day-1 + +FR + +Fraction biomass to roots + +DVS_Partitioning + +FS + +Fraction biomass to stems + +DVS_Partitioning + +Storage organ dynamics +classpcse.crop.storage_organ_dynamics.WOFOST_Storage_Organ_Dynamics(**kwargs)[source] +Implementation of storage organ dynamics. + +Storage organs are the most simple component of the plant in WOFOST and consist of a static pool of biomass. Growth of the storage organs is the result of assimilate partitioning. Death of storage organs is not implemented and the corresponding rate variable (DRSO) is always set to zero. + +Pods are green elements of the plant canopy and can as such contribute to the total photosynthetic active area. This is expressed as the Pod Area Index which is obtained by multiplying pod biomass with a fixed Specific Pod Area (SPA). + +Simulation parameters + +Name + +Description + +Type + +Unit + +TDWI + +Initial total crop dry weight + +SCr + +kg ha-1 + +SPA + +Specific Pod Area + +SCr + +ha kg-1 + +State variables + +Name + +Description + +Pbl + +Unit + +PAI + +Pod Area Index + +Y + +WSO + +Weight of living storage organs + +Y + +kg ha-1 + +DWSO + +Weight of dead storage organs + +N + +kg ha-1 + +TWSO + +Total weight of storage organs + +Y + +kg ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +GRSO + +Growth rate storage organs + +N + +kg ha-1day-1 + +DRSO + +Death rate storage organs + +N + +kg ha-1day-1 + +GWSO + +Net change in storage organ biomass + +N + +kg ha-1day-1 + +Signals send or handled + +None + +External dependencies + +Name + +Description + +Provided by + +Unit + +ADMI + +Above-ground dry matter increase + +CropSimulation + +kg ha-1day-1 + +FO + +Fraction biomass to storage organs + +DVS_Partitioning + +FR + +Fraction biomass to roots + +DVS_Partitioning + +Crop N dynamics +classpcse.crop.n_dynamics.N_Crop_Dynamics(**kwargs)[source] +Implementation of overall N crop dynamics. + +NPK_Crop_Dynamics implements the overall logic of N book-keeping within the crop. + +Simulation parameters + +Name + +Description + +Unit + +NMAXLV_TB + +Maximum N concentration in leaves as function of dvs + +kg N kg-1 dry biomass + +NMAXRT_FR + +Maximum N concentration in roots as fraction of maximum N concentration in leaves + +NMAXST_FR + +Maximum N concentration in stems as fraction of maximum N concentration in leaves + +NRESIDLV + +Residual N fraction in leaves + +kg N kg-1 dry biomass + +NRESIDRT + +Residual N fraction in roots + +kg N kg-1 dry biomass + +NRESIDST + +Residual N fraction in stems + +kg N kg-1 dry biomass + +State variables + +Name + +Description + +Unit + +NamountLV + +Actual N amount in living leaves + +kg N ha-1 + +NamountST + +Actual N amount in living stems + +kg N ha-1 + +NamountSO + +Actual N amount in living storage organs + +kg N ha-1 + +NamountRT + +Actual N amount in living roots + +kg N ha-1 + +Nuptake_T + +total absorbed N amount + +kg N ha-1 + +Nfix_T + +total biological fixated N amount + +kg N ha-1 + +Rate variables + +Name + +Description + +Unit + +RNamountLV + +Weight increase (N) in leaves + +kg N ha-1 d-1 + +RNamountST + +Weight increase (N) in stems + +kg N ha-1 d-1 + +RNamountRT + +Weight increase (N) in roots + +kg N ha-1 d-1 + +RNamountSO + +Weight increase (N) in storage organs + +kg N ha-1 d-1 + +RNdeathLV + +Rate of N loss in leaves + +kg N ha-1 d-1 + +RNdeathST + +Rate of N loss in roots + +kg N ha-1 d-1 + +RNdeathRT + +Rate of N loss in stems + +kg N ha-1 d-1 + +RNloss + +N loss due to senescence + +kg N ha-1 d-1 + +Signals send or handled + +None + +External dependencies + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +WLV + +Dry weight of living leaves + +WOFOST_Leaf_Dynamics + +kg ha-1 + +WRT + +Dry weight of living roots + +WOFOST_Root_Dynamics + +kg ha-1 + +WST + +Dry weight of living stems + +WOFOST_Stem_Dynamics + +kg ha-1 + +DRLV + +Death rate of leaves + +WOFOST_Leaf_Dynamics + +kg ha-1day-1 + +DRRT + +Death rate of roots + +WOFOST_Root_Dynamics + +kg ha-1day-1 + +DRST + +Death rate of stems + +WOFOST_Stem_Dynamics + +kg ha-1day-1 + +classpcse.crop.nutrients.N_Demand_Uptake(**kwargs)[source] +Calculates the crop N demand and its uptake from the soil. + +Crop N demand is calculated as the difference between the actual N (kg N per kg biomass) in the vegetative plant organs (leaves, stems and roots) and the maximum N concentration for each organ. N uptake is then estimated as the minimum of supply from the soil and demand from the crop. + +Nitrogen fixation (leguminous plants) is calculated by assuming that a fixed fraction of the daily N demand is supplied by nitrogen fixation. The remaining part has to be supplied by the soil. + +The N demand of the storage organs is calculated in a somewhat different way because it is assumed that the demand from the storage organs is fulfilled by translocation of N/P/K from the leaves, stems and roots. Therefore the uptake of the storage organs is calculated as the minimum of the daily translocatable N supply and the demand from the storage organs. + +Simulation parameters + +Name + +Description + +Unit + +NMAXLV_TB + +Maximum N concentration in leaves as function of DVS + +kg N kg-1 dry biomass + +NMAXRT_FR + +Maximum N concentration in roots as fraction of maximum N concentration in leaves + +NMAXST_FR + +Maximum N concentration in stems as fraction of maximum N concentration in leaves + +NMAXSO + +Maximum N concentration in storage organs + +kg N kg-1 dry biomass + +NCRIT_FR + +Critical N concentration as fraction of maximum N concentration for vegetative plant organs as a whole (leaves + stems) + +TCNT + +Time coefficient for N translation to storage organs + +days + +NFIX_FR + +fraction of crop nitrogen uptake by + +kg N kg-1 dry biomass biological fixation + +RNUPTAKEMAX + +Maximum rate of N uptake + +kg N ha-1 d-1 + +State variables + +Rate variables + +Name + +Description + +Pbl + +Unit + +RNuptakeLV + +Rate of N uptake in leaves + +Y + +kg N ha-1 d-1 + +RNuptakeST + +Rate of N uptake in stems + +Y + +kg N ha-1 d-1 + +RNuptakeRT + +Rate of N uptake in roots + +Y + +kg N ha-1 d-1 + +RNuptakeSO + +Rate of N uptake in storage organs + +Y + +kg N ha-1 d-1 + +RNuptake + +Total rate of N uptake + +Y + +kg N ha-1 d-1 + +RNfixation + +Rate of N fixation + +Y + +kg N ha-1 d-1 + +NdemandLV + +N Demand in living leaves + +N + +kg N ha-1 + +NdemandST + +N Demand in living stems + +N + +kg N ha-1 + +NdemandRT + +N Demand in living roots + +N + +kg N ha-1 + +NdemandSO + +N Demand in storage organs + +N + +kg N ha-1 + +Ndemand + +Total crop N demand + +N + +kg N ha-1 d-1 + +Signals send or handled + +None + +External dependencies + +classpcse.crop.nutrients.N_Stress(**kwargs)[source] +Implementation of N stress calculation through [N]nutrition index. + +HB 20220405 A lot of changes have been done in this subroutine. It needs to be redocumented. + +Name + +Description + +Unit + +NMAXLV_TB + +Maximum N concentration in leaves as function of DVS + +kg N kg-1 dry matter + +NMAXRT_FR + +Maximum N concentration in roots as fraction of maximum N concentration in leaves + +NMAXSO + +Maximum N oconcentration in grains + +kg N kg-1 dry matter + +NMAXST_FR + +Maximum N concentration in stems as fraction of maximum N concentration in leaves + +NCRIT_FR + +Critical N concentration as fraction of maximum N concentration for vegetative plant organs as a whole (leaves + stems) + +NRESIDLV + +Residual N fraction in leaves + +kg N kg-1 dry matter + +NRESIDST + +Residual N fraction in stems + +kg N kg-1 dry matter + +RGRLAI_MIN + +Relative growth rate in exponential growth phase at maximum N stress + +d-1 + +Rate variables + +The rate variables here are not real rate variables in the sense that they are derived state variables and do not represent a rate. However, as they are directly used in the rate variable calculation it is logical to put them here. + +Name + +Description + +Pbl + +Unit + +NSLLV + +N Stress factor + +Y + +RFRGRL + +Reduction factor relative growth rate in exponential phase + +Y + +External dependencies: + +Abiotic damage +classpcse.crop.abioticdamage.FROSTOL(**kwargs)[source] +Implementation of the FROSTOL model for frost damage in winter-wheat. + +Parameters +: +day – start date of the simulation + +kiosk – variable kiosk of this PCSE instance + +parvalues – ParameterProvider object providing parameters as key/value pairs + +Simulation parameters + +Name + +Description + +Type + +Unit + +IDSL + +Switch for phenological development options temperature only (IDSL=0), including daylength (IDSL=1) and including vernalization (IDSL>=2). FROSTOL requires IDSL>=2 + +SCr + +LT50C + +Critical LT50 defined as the lowest LT50 value that the wheat cultivar can obtain + +SCr + + +FROSTOL_H + +Hardening coefficient + +SCr + + +FROSTOL_D + +Dehardening coefficient + +SCr + + +FROSTOL_S + +Low temperature stress coefficient + +SCr + + +FROSTOL_R + +Respiration stress coefficient + +SCr + +day-1 + +FROSTOL_SDBASE + +Minimum snow depth for respiration stress + +SCr + +cm + +FROSTOL_SDMAX + +Snow depth with maximum respiration stress. Larger snow depth does not increase stress anymore. + +SCr + +cm + +FROSTOL_KILLCF + +Steepness coefficient for logistic kill function. + +SCr + +ISNOWSRC + +Use prescribed snow depth from driving variables (0) or modelled snow depth through the kiosk (1) + +SSi + +State variables + +Name + +Description + +Pbl + +Unit + +LT50T + +Current LT50 value + +N + + +LT50I + +Initial LT50 value of unhardened crop + +N + + +IDFST + +Total number of days with frost stress + +N + +Rate variables + +Name + +Description + +Pbl + +Unit + +RH + +Rate of hardening + +N + + +RDH_TEMP + +Rate of dehardening due to temperature + +N + + +RDH_RESP + +Rate of dehardening due to respiration stress + +N + + +RDH_TSTR + +Rate of dehardening due to temperature stress + +N + + +IDFS + +Frost stress, yes (1) or no (0). Frost stress is defined as: RF_FROST > 0 + +N + +RF_FROST + +Reduction factor on leave biomass as a function of min. crown temperature and LT50T: ranges from 0 (no damage) to 1 (complete kill). + +Y + +RF_FROST_T + +Total frost kill through the growing season is computed as the multiplication of the daily frost kill events, 0 means no damage, 1 means total frost kill. + +N + +External dependencies: + +Name + +Description + +Provided by + +Unit + +TEMP_CROWN + +Daily average crown temperature derived from calling the crown_temperature module. + +CrownTemperature + + +TMIN_CROWN + +Daily minimum crown temperature derived from calling the crown_temperature module. + +CrownTemperature + + +ISVERNALISED + +Boolean reflecting the vernalisation state of the crop. + +Vernalisation i.c.m. with DVS_Phenology module + +Reference: Anne Kari Bergjord, Helge Bonesmo, Arne Oddvar Skjelvag, 2008. +Modelling the course of frost tolerance in winter wheat: I. Model development, European Journal of Agronomy, Volume 28, Issue 3, April 2008, Pages 321-330. + +http://dx.doi.org/10.1016/j.eja.2007.10.002 + +classpcse.crop.abioticdamage.CrownTemperature(**kwargs)[source] +Implementation of a simple algorithm for estimating the crown temperature (2cm under the soil surface) under snow. + +Is is based on a simple empirical equation which estimates the daily minimum, maximum and mean crown temperature as a function of daily min or max temperature and the relative snow depth (RSD): + + +and + + +and + + +and + + +At zero snow depth crown temperature is estimated close the the air temperature. Increasing snow depth acts as a buffer damping the effect of low air temperature on the crown temperature. The maximum value of the snow depth is limited on 15cm. Typical values for A and B are 0.2 and 0.5 + +Note that the crown temperature is only estimated if drv.TMIN<0, otherwise the TMIN, TMAX and daily average temperature (TEMP) are returned. + +Parameters +: +day – day when model is initialized + +kiosk – VariableKiosk of this instance + +parvalues – ParameterProvider object providing parameters as key/value pairs + +Returns +: +a tuple containing minimum, maximum and daily average crown temperature. + +Simulation parameters + +Name + +Description + +Type + +Unit + +ISNOWSRC + +Use prescribed snow depth from driving variables (0) or modelled snow depth through the kiosk (1) + +SSi + +CROWNTMPA + +A parameter in equation for crown temperature + +SSi + +CROWNTMPB + +B parameter in equation for crown temperature + +SSi + +Rate variables + +Name + +Description + +Pbl + +Unit + +TEMP_CROWN + +Daily average crown temperature + +N + + +TMIN_CROWN + +Daily minimum crown temperature + +N + + +TMAX_CROWN + +Daily maximum crown temperature + +N + + +Note that the calculated crown temperatures are not real rate variables as they do not pertain to rate of change. In fact they are a derived driving variable. Nevertheless for calculating the frost damage they should become available during the rate calculation step and by treating them as rate variables, they can be found by a get_variable() call and thus be defined in the list of OUTPUT_VARS in the configuration file + +External dependencies: + +Name + +Description + +Provided by + +Unit + +SNOWDEPTH + +Depth of snow cover. + +Prescibed by driving variables or simulated by snow cover module and taken from kiosk + + +Crop simulation processes for LINGRA +Implementation of the LINGRA grassland simulation model + +This module provides an implementation of the LINGRA (LINtul GRAssland) simulation model for grasslands as described by Schapendonk et al. 1998 (https://doi.org/10.1016/S1161-0301(98)00027-6) for use within the Python Crop Simulation Environment. + +Overall grassland model +classpcse.crop.lingra.LINGRA(**kwargs)[source] +Top level implementation of LINGRA, integrating all components + +This class integrates all components from the LINGRA model and includes the main state variables related to weights of the different biomass pools, the leaf area, tiller number and leaf length. The integrated components include the implementations for source/sink limited growth, soil temperature, evapotranspiration and root dynamics. The latter two are taken from WOFOST in order to avoid duplication of code. + +Compared to the original code from Schapendonk et al. (1998) several improvements have been made: + +an overall restructuring of the code, removing unneeded variables and renaming the remaining variables to have more readable names. + +A clearer implementation of sink/source limited growth including the use of reserves + +the potential leaf elongation rate as calculated by the Sink-limited growth module is now corrected for actual growth. Thereby avoiding unlimited leaf growth under water-stressed conditions which led to unrealistic results. + +Simulation parameters: + +Name + +Description + +Unit + +LAIinit + +Initial leaf area index + +TillerNumberinit + +Initial number of tillers + +tillers/m2 + +WeightREinit + +Initial weight of reserves + +kg/ha + +WeightRTinit + +Initial weight of roots + +kg/ha + +LAIcrit + +Critical LAI for death due to self-shading + +RDRbase + +Background relative death rate for roots + +d-1 + +RDRShading + +Max relative death rate of leaves due to self-shading + +d-1 + +RDRdrought + +Max relative death rate of leaves due to drought stress + +d-1 + +SLA + +Specific leaf area + +ha/kg + +TempBase + +Base temperature for photosynthesis and development + +C + +PartitioningRootsTB + +Partitioning fraction to roots as a function of the reduction factor for transpiration (RFTRA) + +-, - + +TSUMmax + +Temperature sum to max development stage + +C.d + +Rate variables + +Name + +Description + +Unit + +dTSUM + +Change in temperature sum for development + +C + +dLAI + +Net change in Leaf Area Index + +d-1 + +dDaysAfterHarvest + +Change in Days after Harvest + +dCuttingNumber + +Change in number of cuttings (harvests) + +dWeightLV + +Net change in leaf weight + +kg/ha/d + +dWeightRE + +Net change in reserve pool + +kg/ha/d + +dLeafLengthAct + +Change in actual leaf length + +cm/d + +LVdeath + +Leaf death rate + +kg/ha/d + +LVgrowth + +Leaf growth rate + +kg/ha/d + +dWeightHARV + +Change in harvested dry matter + +kg/ha/d + +dWeightRT + +Net change in root weight + +kg/ha/d + +LVfraction + +Fraction partitioned to leaves + +RTfraction + +Fraction partitioned to roots + +State variables + +Name + +Description + +Unit + +TSUM + +Temperature sum + +C d + +LAI + +Leaf area Index + +DaysAfterHarvest + +number of days after harvest + +d + +CuttingNumber + +number of cuttings (harvests) + +TillerNumber + +Tiller number + +tillers/m2 + +WeightLVgreen + +Weight of green leaves + +kg/ha + +WeightLVdead + +Weight of dead leaves + +kg/ha + +WeightHARV + +Weight of harvested dry matter + +kg/ha + +WeightRE + +Weight of reserves + +kg/ha + +WeightRT + +Weight of roots + +kg/ha + +LeafLength + +Length of leaves + +kg/ha + +WeightABG + +Total aboveground weight (harvested + current) + +kg/ha + +SLAINT + +Integrated SLA during the season + +ha/kg + +DVS + +Development stage + +Signals sent or handled + +Mowing of grass will take place when a pcse.signals.mowing event is broadcasted. This will reduce the amount of living leaf weight assuming that a certain amount of biomass will remain on the field (this is a parameter on the MOWING event). + +External dependencies: + +Name + +Description + +Provided by + +RFTRA + +Reduction factor for transpiration + +pcse.crop.Evapotranspiration + +dLeafLengthPot + +Potential growth in leaf length + +pcse.crop.lingra.SinkLimitedGrowth + +dTillerNumber + +Change in tiller number + +pcse.crop.lingra.SinkLimitedGrowth + +Source/Sink limited growth +classpcse.crop.lingra.SourceLimitedGrowth(**kwargs)[source] +Calculates the source-limited growth rate for grassland based on radiation and temperature as driving variables and possibly limited by soil moisture or leaf nitrogen content.The latter is based on static values for current and maximum N concentrations and is mainly there for connecting an N module in the future. + +This routine uses a light use efficiency (LUE) approach where the LUE is adjusted for effects of temperature and radiation level. The former is need as photosynthesis has a clear temperature response. The latter is required as photosynthesis rate flattens off at higher radiation levels which leads to a lower ‘apparent’ light use efficiency. The parameter LUEreductionRadiationTB is a crude empirical correction for this effect. + +Note that a reduction in growth rate due to soil moisture is obtained through the reduction factor for transpiration (RFTRA). + +This module does not provide any true rate variables, but returns the computed growth rate directly to the calling routine through __call__(). + +Simulation parameters: + +Name + +Description + +Unit + +KDIFTB + +Extinction coefficient for diffuse visible as function of DVS. + +CO2A + +Atmospheric CO2 concentration + +ppm + +LUEreductionSoilTempTB + +Reduction function for light use efficiency as a function of soil temperature. + +C, - + +LUEreductionRadiationTB + +Reduction function for light use efficiency as a function of radiation level. + +MJ, - + +LUEmax + +Maximum light use efficiency. + +Rate variables + +Name + +Description + +Unit + +RF_RadiationLevel + +Reduction factor for light use efficiency due to the radiation level + +RF_RadiationLevel + +Reduction factor for light use efficiency due to the radiation level + +LUEact + +The actual light use efficiency + +g /(MJ PAR) + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +DVS + +Crop development stage + +pylingra.LINGRA + +TemperatureSoil + +Soil Temperature + +pylingra.SoilTemperature + +RFTRA + +Reduction factor for transpiration + +pcse.crop.Evapotranspiration + +classpcse.crop.lingra.SinkLimitedGrowth(**kwargs)[source] +Calculates the sink-limited growth rate for grassland assuming a temperature driven maximum leaf elongation rate multiplied by the number of tillers. The conversion to growth in kg/ha dry matter is done by dividing by the specific leaf area (SLA). + +Besides the sink-limited growth rate, this class also computes the change in tiller number taking into account the growth rate, death rate and number of days after defoliation due to harvest. + +Simulation parameters: + +Name + +Description + +Unit + +TempBase + +Base temperature for leaf development and grass phenology + +C + +LAICrit + +Cricical leaf area beyond which leaf death due to self-shading occurs + +SiteFillingMax + +Maximum site filling for new buds + +tiller/leaf-1 + +SLA + +Specific leaf area + +ha/kg + +TSUMmax + +Temperature sum to max development stage + +C.d + +TillerFormRateA0 + +A parameter in the equation for tiller formation rate valid up till 7 days after harvest + +TillerFormRateB0 + +B parameter in the equation for tiller formation rate valid up till 7 days after harvest + +TillerFormRateA8 + +A parameter in the equation for tiller formation rate starting from 8 days after harvest + +TillerFormRateB8 + +B parameter in the equation for tiller formation rate starting from 8 days after harvest + +Rate variables + +Name + +Description + +Unit + +dTillerNumber + +Change in tiller number due to the radiation level + +tillers/m2/d + +dLeafLengthPot + +Potential change in leaf length. Later on the actual change in leaf length will be computed taking source limitation into account. + +cm/d + +LAIGrowthSink + +Growth of LAI based on sink-limited growth rate. + +d-1 + +Signals send or handled + +None + +External dependencies: + +Name + +Description + +Provided by + +DVS + +Crop development stage + +pylingra.LINGRA + +LAI + +Leaf Area Index + +pylingra.LINGRA + +TemperatureSoil + +Soil Temperature + +pylingra.SoilTemperature + +RF_Temperature + +Reduction factor for LUE based on temperature + +pylingra.SourceLimitedGrowth + +TillerNumber + +Actual number of tillers + +pylingra.LINGRA + +LVfraction + +Fraction of assimilates going to leaves + +pylingra.LINGRA + +dWeightHARV + +Change in harvested weight (indicates that a harvest took place today) + +pylingra.LINGRA + +Nitrogen dynamics +classpcse.crop.lingra_ndynamics.N_Demand_Uptake(**kwargs)[source] +Calculates the crop N demand and its uptake from the soil. + +Crop N demand is calculated as the difference between the actual N concentration (kg N per kg biomass) in the vegetative plant organs (leaves, stems and roots) and the maximum N concentration for each organ. N uptake is then estimated as the minimum of supply from the soil and demand from the crop. + +Simulation parameters + +Rate variables + +Name + +Description + +Pbl + +Unit + +RNuptakeLV + +Rate of N uptake in leaves + +Y + +kg N ha-1 d-1 + +RNuptakeRT + +Rate of N uptake in roots + +Y + +kg N ha-1 d-1 + +RNuptake + +Total rate of N uptake + +Y + +kg N ha-1 d-1 + +NdemandLV + +Ndemand of leaves based on current growth rate and deficienties from previous time steps + +N + +kg N ha-1 + +NdemandRT + +N demand of roots, idem as leaves + +N + +kg N ha-1 + +Ndemand + +Total N demand (leaves + roots) + +N + +kg N ha-1 + +Signals send or handled + +None + +External dependencies + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +NAVAIL + +Total available N from soil + +NPK_Soil_Dynamics + +kg ha-1 + +classpcse.crop.lingra_ndynamics.N_Stress(**kwargs)[source] +Implementation of N stress calculation through nitrogen nutrition index. + +Stress factors are calculated based on the mass concentrations of N in the vegetative biomass of the plant. For each pool of nutrients, four concentrations are calculated based on the biomass for leaves and stems: - the actual concentration based on the actual amount of nutrients + +divided by the vegetative biomass. + +The maximum concentration, being the maximum that the plant can absorb into its leaves and stems. + +The critical concentration, being the concentration that is needed to maintain growth rates that are not limited by N (regulated by NCRIT_FR). For N, the critical concentration can be lower than the maximum concentration. This concentration is sometimes called ‘optimal concentration’. + +The residual concentration which is the amount that is locked into the plant structural biomass and cannot be mobilized anymore. + +The stress index (SI) is determined as a simple ratio between those concentrations according to: + +\(SI = (C_{a) - C_{r})/(C_{c} - C_{r})\) + +with subscript a, r and c being the actual, residual and critical concentration for the nutrient. This results in the nitrogen nutrition index (NNI). Finally, the reduction factor for assimilation (RFNUTR) is calculated using the reduction factor for light use efficiency (NLUE). + +Simulation parameters + +Rate variables + +The rate variables here are not real rate variables in the sense that they are derived state variables and do not represent a rate. However, as they are directly used in the rate variable calculation it is logical to put them here. + +Name + +Description + +Pbl + +Unit + +NNI + +Nitrogen nutrition index + +Y + +RFNUTR + +Reduction factor for light use efficiency + +Y + +External dependencies: + +Name + +Description + +Provided by + +Unit + +DVS + +Crop development stage + +DVS_Phenology + +WST + +Dry weight of living stems + +WOFOST_Stem_Dynamics + +kg ha-1 + +WeightLVgreen + +Dry weight of living leaves + +WOFOST_Leaf_Dynamics + +kg ha-1 + +NamountLV + +Amount of N in leaves + +N_Crop_Dynamics + +kg ha-1 + +classpcse.crop.lingra_ndynamics.N_Crop_Dynamics(**kwargs)[source] +Implementation of overall N crop dynamics. + +NPK_Crop_Dynamics implements the overall logic of N book-keeping within the crop. + +Simulation parameters + +State variables + +Name + +Description + +Pbl + +Unit + +NamountLV + +Actual N amount in living leaves + +Y + +kg N ha-1 + +NamountRT + +Actual N amount in living roots + +Y + +kg N ha-1 + +Nuptake_T + +total absorbed N amount + +N + +kg N ha-1 + +Nlosses_T + +Total N amount lost due to senescence + +N + +kg N ha-1 + +Rate variables + +Name + +Description + +Pbl + +Unit + +RNamountLV + +Weight increase (N) in leaves + +N + +kg ha-1day-1 + +RNamountRT + +Weight increase (N) in roots + +N + +kg ha-1day-1 + +RNdeathLV + +Rate of N loss in leaves + +N + +kg ha-1day-1 + +RNdeathRT + +Rate of N loss in roots + +N + +kg ha-1day-1 + +RNloss + +N loss due to senescence + +N + +kg ha-1day-1 + +Signals send or handled + +None + +External dependencies + +Crop simulation processes for LINTUL +classpcse.crop.lintul3.Lintul3(**kwargs)[source] +LINTUL3 is a crop model that calculates biomass production based on intercepted photosynthetically active radiation (PAR) and light use efficiency (LUE). It is an adapted version of LINTUL2 (that simulates potential and water-limited crop growth), including nitrogen limitation. Nitrogen stress in the model is defined through the nitrogen nutrition index (NNI): the ratio of actual nitrogen concentration and critical nitrogen concentration in the plant. The effect of nitrogen stress on crop growth is tested in the model either through a reduction in LUE or leaf area (LA) or a combination of these two and further evaluated with independent datasets. However, water limitation is not considered in the present study as the crop is paddy rice. This paper describes the model for the case of rice, test the hypotheses of N stress on crop growth and details of model calibration and testing using independent data sets of nitrogen treatments (with fertilizer rates of 0 - 400 kgNha-1) under varying environmental conditions in Asia. Results of calibration and testing are compared graphically, through Root Mean Square Deviation (RMSD), and by Average Absolute Deviation (AAD). Overall average absolute deviation values for calibration and testing of total aboveground biomass show less than 26% mean deviation from the observations though the values for individual experiments show a higher deviation up to 41%. In general, the model responded well to nitrogen stress in all the treatments without fertilizer application as observed, but between fertilized treatments the response was varying. + +Nitrogen demand, uptake and stress + +At sub-optimal nitrogen availability in the soil, nitrogen demand of the crop cannot be satisfied, which leads to sub-optimal crop nitrogen concentration. The crop nitrogen concentration below which a crop experiences nitrogen stress is called the critical nitrogen concentration. Nitrogen stress results in reduced rates of biomass production and eventually in reduced yields. Actual N content is the accumulated N above residual (which forms part of the cell structure). The critical N content is the one corresponding to half of the maximum. Nitrogen contents of these three reference points include those of leaves and stems, whereas roots are not considered since N contents of above-ground (green) parts are more important for photosynthesis, because of their chlorophyll content. However, calculation of N demand and N uptake also includes the belowground part. + +See: M.E. Shibu, P.A. Leffelaar, H. van Keulena, P.K. Aggarwal (2010). LINTUL3, a simulation model for nitrogen-limited situations: application to rice. Eur. J. Agron. (2010) http://dx.doi.org/10.1016/j.eja.2010.01.003 + +Parameters + +Name + +Description + +Unit + +DVSI + +Initial development stage + +DVSDR + +Development stage above which deathOfLeaves of leaves and roots start + +DVSNLT + +Development stage after which no nutrients are absorbed + +DVSNT + +development stage above which N translocation to storage organs does occur + +FNTRT + +Nitrogen translocation from roots to storage organs as a fraction of total amount of nitrogen translocated from leaves and stem to storage organs + +FRNX + +Critical N, as a fraction of maximum N concentration + +K + +Light attenuation coefficient + +m²/m² + +LAICR + +critical LAI above which mutual shading of leaves occurs, + +°C/d + +LRNR + +Maximum N concentration of root as fraction of that of leaves + +g/g + +LSNR + +Maximum N concentration of stem as fraction of that of leaves + +g/g + +LUE + +Light use efficiency + +g/MJ + +NLAI + +Coefficient for the effect of N stress on LAI reduction(during juvenile phase) + +NLUE + +Coefficient of reduction of LUE under nitrogen stress, epsilon + +NMAXSO + +Maximum concentration of nitrogen in storage organs + +g/g + +NPART + +Coefficient for the effect of N stress on leaf biomass reduction + +NSLA + +Coefficient for the effect of N stress on SLA reduction + +RDRNS + +Relative death rate of leaf weight due to N stress + +1/d + +RDRRT + +Relative death rate of roots + +1/d + +RDRSHM + +and the maximum dead rate of leaves due to shading + +1/d + +RGRL + +Relative growth rate of LAI at the exponential growth phase + +°C/d + +RNFLV + +Residual N concentration in leaves + +g/g + +RNFRT + +Residual N concentration in roots + +g/g + +RNFST + +Residual N concentration in stem + +g/g + +ROOTDM + +Maximum root depth + +m + +RRDMAX + +Maximum rate of increase in rooting depth + +m/d + +SLAC + +Specific leaf area constant + +m²/g + +TBASE + +Base temperature for crop development + +°C + +TCNT + +Time coefficient for N translocation + +d + +TRANCO + +Transpiration constant indicating the level of drought tolerance of the wheat crop + +mm/d + +TSUMAG + +Temperature sum for ageing of leaves + +°C.d + +WCFC + +Water content at field capacity (0.03 MPa) + +m³/m³ + +WCST + +Water content at full saturation + +m³/m³ + +WCWET + +Critical Water content for oxygen stress + +m³/m³ + +WCWP + +Water content at wilting point (1.5 MPa) + +m³/m³ + +WMFAC + +water management (False = irrigated up to the field capacity, true= irrigated up to saturation) + +(bool) + +RNSOIL + +Daily amount of N available in the soil through mineralisation of organic matter + +Tabular parameters + +Name + +Description + +Unit + +FLVTB + +Partitioning coefficients + +FRTTB + +Partitioning coefficients + +FSOTB + +Partitioning coefficients + +FSTTB + +Partitioning coefficients + +NMXLV + +Maximum N concentration in the leaves, from which the values of the stem and roots are derived, as a function of development stage + +kg N kg-1 dry biomass + +RDRT + +Relative death rate of leaves as a function of Developmental stage + +1/d + +SLACF + +Leaf area correction function as a function of development stage, DVS. Reference: Drenth, H., ten Berge, H.F.M. and Riethoven, J.J.M. 1994, p.10. (Complete reference under Observed data.) + +initial states * + +Name + +Description + +Unit + +ROOTDI + +Initial rooting depth + +m + +NFRLVI + +Initial fraction of N in leaves + +gN/gDM + +NFRRTI + +Initial fraction of N in roots + +gN/gDM + +NFRSTI + +Initial fraction of N in stem + +gN/gDM + +WCI + +Initial water content in soil + +m³/³ + +WLVGI + +Initial Weight of green leaves + +g/m² + +WSTI + +Initial Weight of stem + +g/m² + +WRTLI + +Initial Weight of roots + +g/m² + +WSOI + +Initial Weight of storage organs + +g/m² + +State variables: + +Name + +Description + +Pbl + +Unit + +ANLV + +Actual N content in leaves + +ANRT + +Actual N content in root + +ANSO + +Actual N content in storage organs + +ANST + +Actual N content in stem + +CUMPAR + +PAR accumulator + +LAI + +leaf area index + +m²/m² + +NLOSSL + +total N loss by leaves + +NLOSSR + +total N loss by roots + +NUPTT + +Total uptake of N over time + +gN/m² + +ROOTD + +Rooting depth + +m + +TNSOIL + +Amount of inorganic N available for crop uptake + +WDRT + +dead roots (?) + +g/m² + +WLVD + +Weight of dead leaves + +g/m² + +WLVG + +Weight of green leaves + +g/m² + +WRT + +Weight of roots + +g/m² + +WSO + +Weight of storage organs + +g/m² + +WST + +Weight of stem + +g/m² + +TAGBM + +Total aboveground biomass + +g/m² + +TGROWTH + +Total biomass growth (above and below ground) + +g/m² + +Rate variables: + +Name + +Description + +Pbl + +Unit + +PEVAP + +Potential soil evaporation rate + +Y + +|mmday-1| + +PTRAN + +Potential crop transpiration rate + +Y + +|mmday-1| + +TRAN + +Actual crop transpiration rate + +N + +|mmday-1| + +TRANRF + +Transpiration reduction factor calculated + +N + +RROOTD + +Rate of root growth + +Y + +|mday-1| + +classLintul3Rates(**kwargs)[source] +classLintul3States(**kwargs)[source] +N_uptakeRates(NDEML, NDEMS, NDEMR, NUPTR, NDEMTO)[source] +Compute the partitioning of the total N uptake rate (NUPTR) over the leaves, stem, and roots. + +classParameters(**kwargs)[source] +deathRateOfLeaves(TSUM, RDRTMP, NNI, SLA)[source] +Compute the relative death rate of leaves due to age, shading amd due to nitrogen stress. + +dryMatterPartitioningFractions(NPART, TRANRF, NNI, FRTWET, FLVT, FSTT, FSOT)[source] +Computes the Dry matter partitioning fractions: leaves, stem and storage organs. + +initialize(day, kiosk, parvalues)[source] +Parameters +: +day – start date of the simulation + +kiosk – variable kiosk of this PCSE instance + +parvalues – ParameterProvider object providing parameters as key/value pairs + +relativeGrowthRates(RGROWTH, FLV, FRT, FST, FSO, DLV, DRRT)[source] +Compute the relative total Growth Rate rate of roots, leaves, stem and storage organs. + +totalGrowthRate(DTR, TRANRF, NNI)[source] +Compute the total total Growth Rate. + +Monteith, (1977), Gallagher and Biscoe, (1978) and Monteith (1990) have shown that biomass formed per unit intercepted light, LUE (Light Use Efficiency, g dry matter MJ-1), is relatively stable. Hence, maximum daily growth rate can be defined as the product of intercepted PAR (photosynthetically active radiation, +) and LUE. + +Intercepted PAR depends on incident solar radiation, the fraction that is photosynthetically active (0.5) (Monteith and Unsworth, 1990; Spitters, 1990), and LAI (m 2 leaf m -2 soil) according to Lambert Beer’s law: + + +where Q is intercepted PAR +, Q0 is daily global radiation +, and k is the attenuation coefficient for PAR in the canopy. + +translocatable_N()[source] +Compute the translocatable N in the organs. + +Base classes +The base classes define much of the functionality which is used “under the hood” in PCSE. Except for the VariableKiosk and the WeatherDataContainer all classes are not to be called directly but should be subclassed instead. + +VariableKiosk +classpcse.base.VariableKiosk[source] +VariableKiosk for registering and publishing state variables in PCSE. + +No parameters are needed for instantiating the VariableKiosk. All variables that are defined within PCSE will be registered within the VariableKiosk, while usually only a small subset of those will be published with the kiosk. The value of the published variables can be retrieved with the bracket notation as the variableKiosk is essentially a (somewhat fancy) dictionary. + +Registering/deregistering rate and state variables goes through the self.register_variable() and self.deregister_variable() methods while the set_variable() method is used to update a value of a published variable. In general, none of these methods need to be called by users directly as the logic within the StatesTemplate and RatesTemplate takes care of this. + +Finally, the variable_exists() can be used to check if a variable is registered, while the flush_states() and flush_rates() are used to remove (flush) the values of any published state and rate variables. + +example: + +import pcse +from pcse.base import VariableKiosk + +v = VariableKiosk() +id0 = 0 +v.register_variable(id0, "VAR1", type="S", publish=True) +v.register_variable(id0, "VAR2", type="S", publish=False) + +id1 = 1 +v.register_variable(id1, "VAR3", type="R", publish=True) +v.register_variable(id1, "VAR4", type="R", publish=False) + +v.set_variable(id0, "VAR1", 1.35) +v.set_variable(id1, "VAR3", 310.56) + +print v +Contents of VariableKiosk: + * Registered state variables: 2 + * Published state variables: 1 with values: + - variable VAR1, value: 1.35 + * Registered rate variables: 2 + * Published rate variables: 1 with values: + - variable VAR3, value: 310.56 + +print v["VAR3"] +310.56 +v.set_variable(id0, "VAR3", 750.12) +Traceback (most recent call last): + File "", line 1, in + File "pcse/base.py", line 148, in set_variable + raise exc.VariableKioskError(msg % varname) +pcse.exceptions.VariableKioskError: Unregistered object tried to set the value of variable 'VAR3': access denied. + +v.flush_rates() +print v +Contents of VariableKiosk: + * Registered state variables: 2 + * Published state variables: 1 with values: + - variable VAR1, value: 1.35 + * Registered rate variables: 2 + * Published rate variables: 1 with values: + - variable VAR3, value: undefined + +v.flush_states() +print v +Contents of VariableKiosk: + * Registered state variables: 2 + * Published state variables: 1 with values: + - variable VAR1, value: undefined + * Registered rate variables: 2 + * Published rate variables: 1 with values: + - variable VAR3, value: undefined +deregister_variable(oid, varname)[source] +Object with id(object) asks to deregister varname from kiosk + +Parameters +: +oid – Object id (from python builtin id() function) of the state/rate object registering this variable. + +varname – Name of the variable to be registered, e.g. “DVS” + +flush_rates()[source] +flush the values of all published rate variable from the kiosk. + +flush_states()[source] +flush the values of all state variable from the kiosk. + +register_variable(oid, varname, type, publish=False)[source] +Register a varname from object with id, with given type + +Parameters +: +oid – Object id (from python builtin id() function) of the state/rate object registering this variable. + +varname – Name of the variable to be registered, e.g. “DVS” + +type – Either “R” (rate) or “S” (state) variable, is handled automatically by the states/rates template class. + +publish – True if variable should be published in the kiosk, defaults to False + +set_variable(id, varname, value)[source] +Let object with id, set the value of variable varname + +Parameters +: +id – Object id (from python builtin id() function) of the state/rate object registering this variable. + +varname – Name of the variable to be updated + +value – Value to be assigned to the variable. + +variable_exists(varname)[source] +Returns True if the state/rate variable is registered in the kiosk. + +Parameters +: +varname – Name of the variable to be checked for registration. + +Base classes for parameters, rates and states +classpcse.base.StatesTemplate(**kwargs)[source] +Takes care of assigning initial values to state variables, registering variables in the kiosk and monitoring assignments to variables that are published. + +Parameters +: +kiosk – Instance of the VariableKiosk class. All state variables will be registered in the kiosk in order to enfore that variable names are unique across the model. Moreover, the value of variables that are published will be available through the VariableKiosk. + +publish – Lists the variables whose values need to be published in the VariableKiosk. Can be omitted if no variables need to be published. + +Initial values for state variables can be specified as keyword when instantiating a States class. + +example: + +import pcse +from pcse.base import VariableKiosk, StatesTemplate +from pcse.traitlets import Float, Integer, Instance +from datetime import date + +k = VariableKiosk() +class StateVariables(StatesTemplate): + StateA = Float() + StateB = Integer() + StateC = Instance(date) + +s1 = StateVariables(k, StateA=0., StateB=78, StateC=date(2003,7,3), + publish="StateC") +print s1.StateA, s1.StateB, s1.StateC +0.0 78 2003-07-03 +print k +Contents of VariableKiosk: + * Registered state variables: 3 + * Published state variables: 1 with values: + - variable StateC, value: 2003-07-03 + * Registered rate variables: 0 + * Published rate variables: 0 with values: + + +s2 = StateVariables(k, StateA=200., StateB=1240) +Traceback (most recent call last): + File "", line 1, in + File "pcse/base.py", line 396, in __init__ + raise exc.PCSEError(msg) +pcse.exceptions.PCSEError: Initial value for state StateC missing. +touch()[source] +Re-assigns the value of each state variable, thereby updating its value in the variablekiosk if the variable is published. + +classpcse.base.RatesTemplate(**kwargs)[source] +Takes care of registering variables in the kiosk and monitoring assignments to variables that are published. + +Parameters +: +kiosk – Instance of the VariableKiosk class. All rate variables will be registered in the kiosk in order to enfore that variable names are unique across the model. Moreover, the value of variables that are published will be available through the VariableKiosk. + +publish – Lists the variables whose values need to be published in the VariableKiosk. Can be omitted if no variables need to be published. + +For an example see the StatesTemplate. The only difference is that the initial value of rate variables does not need to be specified because the value will be set to zero (Int, Float variables) or False (Boolean variables). + +zerofy()[source] +Sets the values of all rate values to zero (Int, Float) or False (Boolean). + +classpcse.base.ParamTemplate(**kwargs)[source] +Template for storing parameter values. + +This is meant to be subclassed by the actual class where the parameters are defined. + +example: + +import pcse +from pcse.base import ParamTemplate +from pcse.traitlets import Float + + +class Parameters(ParamTemplate): + A = Float() + B = Float() + C = Float() + +parvalues = {"A" :1., "B" :-99, "C":2.45} +params = Parameters(parvalues) +params.A +1.0 +params.A; params.B; params.C +1.0 +-99.0 +2.4500000000000002 +parvalues = {"A" :1., "B" :-99} +params = Parameters(parvalues) +Traceback (most recent call last): + File "", line 1, in + File "pcse/base.py", line 205, in __init__ + raise exc.ParameterError(msg) +pcse.exceptions.ParameterError: Value for parameter C missing. +Base and utility classes for weather data +classpcse.base.WeatherDataProvider[source] +Base class for all weather data providers. + +Support for weather ensembles in a WeatherDataProvider has to be indicated by setting the class variable supports_ensembles = True + +Example: + +class MyWeatherDataProviderWithEnsembles(WeatherDataProvider): + supports_ensembles = True + + def __init__(self): + WeatherDataProvider.__init__(self) + + # remaining initialization stuff goes here. +check_keydate(key)[source] +Check representations of date for storage/retrieval of weather data. + +The following formats are supported: + +a date object + +a datetime object + +a string of the format YYYYMMDD + +a string of the format YYYYDDD + +Formats 2-4 are all converted into a date object internally. + +export()[source] +Exports the contents of the WeatherDataProvider as a list of dictionaries. + +The results from export can be directly converted to a Pandas dataframe which is convenient for plotting or analyses. + +classpcse.base.WeatherDataContainer(*args, **kwargs)[source] +Class for storing weather data elements. + +Weather data elements are provided through keywords that are also the attribute names under which the variables can accessed in the WeatherDataContainer. So the keyword TMAX=15 sets an attribute TMAX with value 15. + +The following keywords are compulsory: + +Parameters +: +LAT – Latitude of location (decimal degree) + +LON – Longitude of location (decimal degree) + +ELEV – Elevation of location (meters) + +DAY – the day of observation (python datetime.date) + +IRRAD – Incoming global radiaiton (J/m2/day) + +TMIN – Daily minimum temperature (Celsius) + +TMAX – Daily maximum temperature (Celsius) + +VAP – Daily mean vapour pressure (hPa) + +RAIN – Daily total rainfall (cm/day) + +WIND – Daily mean wind speed at 2m height (m/sec) + +E0 – Daily evaporation rate from open water (cm/day) + +ES0 – Daily evaporation rate from bare soil (cm/day) + +ET0 – Daily evapotranspiration rate from reference crop (cm/day) + +There are two optional keywords arguments: + +Parameters +: +TEMP – Daily mean temperature (Celsius), will otherwise be derived from (TMAX+TMIN)/2. + +SNOWDEPTH – Depth of snow cover (cm) + +add_variable(varname, value, unit)[source] +Adds an attribute with and given + +Parameters +: +varname – Name of variable to be set as attribute name (string) + +value – value of variable (attribute) to be added. + +unit – string representation of the unit of the variable. Is only use for printing the contents of the WeatherDataContainer. + +Configuration loading +classpcse.base.ConfigurationLoader(config)[source] +Class for loading the model configuration from a PCSE configuration files + +Parameters +: +config – string given file name containing model configuration + +update_output_variable_lists(output_vars=None, summary_vars=None, terminal_vars=None)[source] +Updates the lists of output variables that are defined in the configuration file. + +This is useful because sometimes you want the flexibility to get access to an additional model variable which is not in the standard list of variables defined in the model configuration file. The more elegant way is to define your own configuration file, but this adds some flexibility particularly for use in jupyter notebooks and exploratory analysis. + +Note that there is a different behaviour given the type of the variable provided. List and string inputs will extend the list of variables, while set/tuple inputs will replace the current list. + +Parameters +: +output_vars – the variable names to add/replace for the OUTPUT_VARS configuration variable + +summary_vars – the variable names to add/replace for the SUMMARY_OUTPUT_VARS configuration variable + +terminal_vars – the variable names to add/replace for the TERMINAL_OUTPUT_VARS configuration variable + +Signals defined +This module defines and describes the signals used by PCSE + +Signals are used by PCSE to notify components of events such as sowing, harvest and termination. Events can be send by any SimulationObject through its SimulationObject._send_signal() method. Similarly, any SimulationObject can receive signals by registering a handler through the SimulationObject._connect_signal() method. Variables can be passed to the handler of the signal through positional or keyword arguments. However, it is highly discouraged to use positional arguments when sending signals in order to avoid conflicts between positional and keyword arguments. + +An example can help to clarify how signals are used in PCSE but check also the documentation of the PyDispatcher package for more information: + +import sys, os +import math +sys.path.append('/home/wit015/Sources/python/pcse/') +import datetime as dt + +import pcse +from pcse.base import SimulationObject, VariableKiosk + +mysignal = "My first signal" + +class MySimObj(SimulationObject): + + def initialize(self, day, kiosk): + self._connect_signal(self.handle_mysignal, mysignal) + + def handle_mysignal(self, arg1, arg2): + print "Value of arg1,2: %s, %s" % (arg1, arg2) + + def send_signal_with_exact_arguments(self): + self._send_signal(signal=mysignal, arg2=math.pi, arg1=None) + + def send_signal_with_more_arguments(self): + self._send_signal(signal=mysignal, arg2=math.pi, arg1=None, + extra_arg="extra") + + def send_signal_with_missing_arguments(self): + self._send_signal(signal=mysignal, arg2=math.pi, extra_arg="extra") + + +# Create an instance of MySimObj +day = dt.date(2000,1,1) +k = VariableKiosk() +mysimobj = MySimObj(day, k) + +# This sends exactly the right amount of keyword arguments +mysimobj.send_signal_with_exact_arguments() + +# this sends an additional keyword argument 'extra_arg' which is ignored. +mysimobj.send_signal_with_more_arguments() + +# this sends the signal with a missing 'arg1' keyword argument which the handler +# expects and thus causes an error, raising a TypeError +try: + mysimobj.send_signal_with_missing_arguments() +except TypeError, exc: + print "TypeError occurred: %s" % exc +Saving this code as a file test_signals.py and importing it gives the following output: + +import test_signals +Value of arg1,2: None, 3.14159265359 +Value of arg1,2: None, 3.14159265359 +TypeError occurred: handle_mysignal() takes exactly 3 non-keyword arguments (1 given) +Currently the following signals are used within PCSE with the following keywords. + +CROP_START + +Indicates that a new crop cycle will start: + +self._send_signal(signal=signals.crop_start, day=, + crop_name=, variety_name=, + crop_start_type=, crop_end_type=) +keyword arguments with signals.crop_start: + +day: Current date + +crop_name: a string identifying the crop + +variety_name: a string identifying the crop variety + +crop_start_type: either ‘sowing’ or ‘emergence’ + +crop_end_type: either ‘maturity’, ‘harvest’ or ‘earliest’ + +CROP_FINISH + +Indicates that the current crop cycle is finished: + +self._send_signal(signal=signals.crop_finish, day=, + finish_type=, crop_delete=) +keyword arguments with signals.crop_finish: + +day: Current date + +finish_type: string describing the reason for finishing the simulation, e.g. maturity, harvest, all leaves died, maximum duration reached, etc. + +crop_delete: Set to True when the CropSimulation object must be deleted from the system, for example for the implementation of crop rotations. Defaults to False. + +TERMINATE + +Indicates that the entire system should terminate (crop & soil water balance) and that terminal output should be collected: + +self._send_signal(signal=signals.terminate) +No keyword arguments are defined for this signal + +OUTPUT + +Indicates that the model state should be saved for later use: + +self._send_signal(signal=signals.output) +No keyword arguments are defined for this signal + +SUMMARY_OUTPUT + +Indicates that the model state should be saved for later use, SUMMARY_OUTPUT is only generated when a CROP_FINISH signal is received indicating that the crop simulation must finish: + +self._send_signal(signal=signals.output) +No keyword arguments are defined for this signal + +APPLY_N + +Is used for application of N fertilizer: + +self._send_signal(signal=signals.apply_n, N_amount=, N_recovery) +Keyword arguments with signals.apply_n: + +N_amount: Amount of fertilizer in kg/ha applied on this day. + +N_recovery: Recovery fraction for the given type of fertilizer + +APPLY_N_SNOMIN + +Is used for application of N fertilizer with the SNOMIN module: + +self._send_signal(signal=signals.apply_n_snomin,amount=, application_depth=, + cnratio=, initial_age=, f_NH4N=, f_NO3N=, + f_orgmat=) +Keyword arguments with signals.apply_n_snomin: + +amount: Amount of material in amendment (kg material ha-1) + +application_depth: Depth over which the amendment is applied in the soil (cm) + +cnratio: C:N ratio of organic matter in material (kg C kg-1 N) + +initial_age: Initial apparent age of organic matter in material (year) + +f_NH4N: Fraction of NH4+-N in material (kg NH4+-N kg-1 material) + +f_NO3N: Fraction of NO3–N in material (kg NO3–N kg-1 material) + +f_orgmat: Fraction of organic matter in amendment (kg OM kg-1 material) + +IRRIGATE + +Is used for sending irrigation events: + +self._send_signal(signal=signals.irrigate, amount=, efficiency=) +Keyword arguments with signals.irrigate: + +amount: Amount of irrigation in cm water applied on this day. + +efficiency: efficiency of irrigation, meaning that the total amount of water that is added to the soil reservoir equals amount * efficiency + +MOWING + +Is used for sending mowing events used by the LINGRA/LINGRA-N models: + +self._send_signal(signal=signals.mowing, biomass_remaining=) +Keyword arguments with signals.mowing: + +biomass_remaining: The amount of biomass remaining after mowing in kg/ha. + +Ancillary code +The ancillary code section deals with tools for reading weather data and parameter values from files or databases. + +Data providers +The module pcse.input contains all classes for reading weather files, parameter files and agromanagement files. + +classpcse.input.NASAPowerWeatherDataProvider(latitude, longitude, force_update=False, ETmodel='PM')[source] +WeatherDataProvider for using the NASA POWER database with PCSE + +Parameters +: +latitude – latitude to request weather data for + +longitude – longitude to request weather data for + +force_update – Set to True to force to request fresh data from POWER website. + +ETmodel – “PM”|”P” for selecting penman-monteith or Penman method for reference evapotranspiration. Defaults to “PM”. + +The NASA POWER database is a global database of daily weather data specifically designed for agrometeorological applications. The spatial resolution of the database is 0.25x0.25 degrees (as of 2018). It is derived from weather station observations in combination with satellite data for parameters like radiation. + +The weather data is updated with a delay of about 5 days on realtime (depending on the variable) which makes the database unsuitable for real-time monitoring, nevertheless the POWER database is useful for many other studies and it is a major improvement compared to the monthly weather data that were used with WOFOST in the past. + +For more information on the NASA POWER database see the documentation at: https://power.larc.nasa.gov/docs/ + +The NASAPowerWeatherDataProvider retrieves the weather from the th NASA POWER API and does the necessary conversions to be compatible with PCSE. After the data has been retrieved and stored, the contents are dumped to a binary cache file. If another request is made for the same location, the cache file is loaded instead of a full request to the NASA Power server. + +Cache files are used until they are older then 90 days. After 90 days the NASAPowerWeatherDataProvider will make a new request to obtain more recent data from the NASA POWER server. If this request fails it will fall back to the existing cache file. The update of the cache file can be forced by setting force_update=True. + +Finally, note that any latitude/longitude within a 0.25x0.25 degrees grid box will yield the same weather data, e.g. there is no difference between lat/lon 5.3/52.1 and lat/lon 5.35/52.2. Nevertheless slight differences in PCSE simulations may occur due to small differences in day length. + +classpcse.input.OpenMeteoWeatherDataProvider(latitude: float, longitude: float, timezone: str = 'UTC', openmeteo_model: str = 'best_match', start_date: str | date | None = None, ETmodel: str = 'PM', forecast: bool = False, force_update: bool = False)[source] +A weather provider that uses the Open Meteo weather API. + +Parameters +: +latitude – latitude to request weather data for + +longitude – longitude to request weather data for + +timezone – timezone for day aggregation (str, default ‘UTC’) + +openmeteo_model – model to use, default ‘best_match’ + +start_date – Starting date from which to retrieve data (str, default ‘UTC’) + +ETmodel – “PM”|”P” for selecting penman-monteith or Penman method for reference evapotranspiration. Defaults to “PM”. + +forecast – Include a weather forecast, default False + +force_update – Set to True to force to request fresh data from OpenMeteo website. + +This object only needs a location (latitude and longitude) at initialization. + +There are two important parameters when constructing the object: openmeteo_model and forecast. The class variables list possible models to use, either for forecasts or historical data. + +To utilize a specific model, call it with the appropriate key argument. Be aware that there might be some nuances with using certain models. This hasn’t been tested thoroughly, so there might be some issues with the starting date. Please provide an argument for the start_date parameter if you find any issues. More info for each model is documented here: https://open-meteo.com/en/docs + +If you don’t specify a model, the Open Meteo API will automatically choose the best model for your chosen location. + +classpcse.input.CABOWeatherDataProvider(fname, fpath=None, ETmodel='PM', distance=1)[source] +Reader for CABO weather files. + +Parameters +: +fname – root name of CABO weather files to read + +fpath – path where to find files, can be absolute or relative. + +ETmodel – “PM”|”P” for selecting penman-monteith or Penman method for reference evapotranspiration. Defaults to “PM”. + +distance – maximum interpolation distance for meteorological variables, defaults to 1 day. + +Returns +: +callable like object with meteo records keyed on date. + +The Wageningen crop models that are written in FORTRAN or FST often use the CABO weather system (http://edepot.wur.nl/43010) for storing and reading weather data. This class implements a reader for the CABO weather files and also implements additional features like interpolation of weather data in case of missing values, conversion of sunshine duration to global radiation estimates and calculation the reference evapotranspiration values for water, soil and plants (E0, ES0, ET0) using the Penman approach. + +A difference with the old CABOWE system is that the python implementation will read and store all files (e.g. years) available for a certain station instead of loading a new file when crossing a year boundary. + +Note + +some conversions are done by the CABOWeaterDataProvider from the units in the CABO weather file for compatibility with WOFOST: + +vapour pressure from kPa to hPa + +radiation from kJ/m2/day to J/m2/day + +rain from mm/day to cm/day. + +all evaporation/transpiration rates are also returned in cm/day. + +Example + +The file ‘nl1.003’ provides weather data for the year 2003 for the station in Wageningen and can be found in the cabowe/ folder of the WOFOST model distribution. This file can be read using: + +weather_data = CABOWeatherDataProvider('nl1', fpath="./meteo/cabowe") +print weather_data(datetime.date(2003,7,26)) +Weather data for 2003-07-26 (DAY) +IRRAD: 12701000.00 J/m2/day + TMIN: 15.90 Celsius + TMAX: 23.00 Celsius + VAP: 16.50 hPa + WIND: 3.00 m/sec + RAIN: 0.12 cm/day + E0: 0.36 cm/day + ES0: 0.32 cm/day + ET0: 0.31 cm/day +Latitude (LAT): 51.97 degr. +Longitude (LON): 5.67 degr. +Elevation (ELEV): 7.0 m. +Alternatively the date in the print command above can be specified as string with format YYYYMMDD or YYYYDDD. + +classpcse.input.ExcelWeatherDataProvider(xls_fname, missing_snow_depth=None, force_reload=False)[source] +Reading weather data from an excel file (.xlsx only). + +Parameters +: +xls_fname – name of the Excel file to be read + +mising_snow_depth – the value that should use for missing SNOW_DEPTH values, the default value is None. + +force_reload – bypass the cache file, reload data from the .xlsx file and write a new cache file. Cache files are written under $HOME/.pcse/meteo_cache + +For reading weather data from file, initially only the CABOWeatherDataProvider was available that reads its data from a text file in the CABO Weather format. Nevertheless, building CABO weather files is tedious as for each year a new file must constructed. Moreover it is rather error prone and formatting mistakes are easily leading to errors. + +To simplify providing weather data to PCSE models, a new data provider was written that reads its data from simple excel files + +The ExcelWeatherDataProvider assumes that records are complete and does not make an effort to interpolate data as this can be easily accomplished in Excel itself. Only SNOW_DEPTH is allowed to be missing as this parameter is usually not provided outside the winter season. + +classpcse.input.CSVWeatherDataProvider(csv_fname, delimiter=',', dateformat='%Y%m%d', ETmodel='PM', force_reload=False)[source] +Reading weather data from a CSV file. + +Parameters +: +csv_fname – name of the CSV file to be read + +delimiter – CSV delimiter + +dateformat – date format to be read. Default is ‘%Y%m%d’ + +ETmodel – “PM”|”P” for selecting Penman-Monteith or Penman method for reference evapotranspiration. Default is ‘PM’. + +force_reload – Ignore cache file and reload from the CSV file + +The CSV file should have the following structure (sample), missing values should be added as ‘NaN’: + +## Site Characteristics +Country = 'Netherlands' +Station = 'Wageningen, Haarweg' +Description = 'Observed data from Station Haarweg in Wageningen' +Source = 'Meteorology and Air Quality Group, Wageningen University' +Contact = 'Peter Uithol' +Longitude = 5.67; Latitude = 51.97; Elevation = 7; AngstromA = 0.18; AngstromB = 0.55; HasSunshine = False +## Daily weather observations (missing values are NaN) +DAY,IRRAD,TMIN,TMAX,VAP,WIND,RAIN,SNOWDEPTH +20040101,NaN,-0.7,1.1,0.55,3.6,0.5,NaN +20040102,3888,-7.5,0.9,0.44,3.1,0,NaN +20040103,2074,-6.8,-0.5,0.45,1.8,0,NaN +20040104,1814,-3.6,5.9,0.66,3.2,2.5,NaN +20040105,1469,3,5.7,0.78,2.3,1.3,NaN +[...] + +with +IRRAD in kJ/m2/day or hours +TMIN and TMAX in Celsius (°C) +VAP in kPa +WIND in m/sec +RAIN in mm +SNOWDEPTH in cm +For reading weather data from a file, initially the CABOWeatherDataProvider was available which read its data from text in the CABO weather format. Nevertheless, building CABO weather files is tedious as for each year a new file must constructed. Moreover it is rather error prone and formatting mistakes are easily leading to errors. + +To simplify providing weather data to PCSE models, a new data provider has been derived from the ExcelWeatherDataProvider that reads its data from simple CSV files. + +The CSVWeatherDataProvider assumes that records are complete and does not make an effort to interpolate data as this can be easily accomplished in a text editor. Only SNOWDEPTH is allowed to be missing as this parameter is usually not provided outside the winter season. + +classpcse.input.CABOFileReader(fname)[source] +Reads CABO files with model parameter definitions. + +The parameter definitions of Wageningen crop models are generally written in the CABO format. This class reads the contents, parses the parameter names/values and returns them as a dictionary. + +Parameters +: +fname – parameter file to read and parse + +Returns +: +dictionary like object with parameter key/value pairs. + +Note that this class does not yet fully support reading all features of CABO files. For example, the parsing of booleans, date/times and tabular parameters is not supported and will lead to errors. + +The header of the CABO file (marked with ** at the first line) is read and can be retrieved by the get_header() method or just by a print on the returned dictionary. + +Example + +A parameter file ‘parfile.cab’ which looks like this: + +** CROP DATA FILE for use with WOFOST Version 5.4, June 1992 +** +** WHEAT, WINTER 102 +** Regions: Ireland, central en southern UK (R72-R79), +** Netherlands (not R47), northern Germany (R11-R14) +CRPNAM='Winter wheat 102, Ireland, N-U.K., Netherlands, N-Germany' +CROP_NO=99 +TBASEM = -10.0 ! lower threshold temp. for emergence [cel] +DTSMTB = 0.00, 0.00, ! daily increase in temp. sum + 30.00, 30.00, ! as function of av. temp. [cel; cel d] + 45.00, 30.00 +** maximum and minimum concentrations of N, P, and K +** in storage organs in vegetative organs [kg kg-1] +NMINSO = 0.0110 ; NMINVE = 0.0030 +Can be read with the following statements: + +>>>fileparameters = CABOFileReader('parfile.cab') +>>>print fileparameters['CROP_NO'] +99 +>>>print fileparameters +** CROP DATA FILE for use with WOFOST Version 5.4, June 1992 +** +** WHEAT, WINTER 102 +** Regions: Ireland, central en southern UK (R72-R79), +** Netherlands (not R47), northern Germany (R11-R14) +------------------------------------ +TBASEM: -10.0 +DTSMTB: [0.0, 0.0, 30.0, 30.0, 45.0, 30.0] +NMINVE: 0.003 +CROP_NO: 99 +CRPNAM: Winter wheat 102, Ireland, N-U.K., Netherlands, N-Germany +NMINSO: 0.011 +classpcse.input.PCSEFileReader(fname)[source] +Reader for parameter files in the PCSE format. + +This class is a replacement for the CABOFileReader. The latter can be used for reading parameter files in the CABO format, however this format has rather severe limitations: it only supports string, integer, float and array parameters. There is no support for specifying parameters with dates for example (other then specifying them as a string). + +The PCSEFileReader is a much more versatile tool for creating parameter files because it leverages the power of the python interpreter for processing parameter files through the execfile functionality in python. This means that anything that can be done in a python script can also be done in a PCSE parameter file. + +Parameters +: +fname – parameter file to read and parse + +Returns +: +dictionary object with parameter key/value pairs. + +Example + +Below is an example of a parameter file ‘parfile.pcse’. Parameters can be defined the ‘CABO’-way, but also advanced functionality can be used by importing modules, defining parameters as dates or numpy arrays and even applying function on arrays (in this case np.sin): + +'''This is the header of my parameter file. + +This file is derived from the following sources +* dummy file for demonstrating the PCSEFileReader +* contains examples how to leverage dates, arrays and functions, etc. +''' + +import numpy as np +import datetime as dt + +TSUM1 = 1100 +TSUM2 = 900 +DTSMTB = [ 0., 0., + 5., 5., + 20., 25., + 30., 25.] +AMAXTB = np.sin(np.arange(12)) +cropname = 'alfalfa' +CROP_START_DATE = dt.date(2010,5,14) +Can be read with the following statements: + +>>>fileparameters = PCSEFileReader('parfile.pcse') +>>>print fileparameters['TSUM1'] +1100 +>>>print fileparameters['CROP_START_DATE'] +2010-05-14 +>>>print fileparameters +PCSE parameter file contents loaded from: +D:\UserData\pcse_examples\parfile.pw + +This is the header of my parameter file. + +This file is derived from the following sources +* dummy file for demonstrating the PCSEFileReader +* contains examples how to leverage dates, arrays and functions, etc. +DTSMTB: [0.0, 0.0, 5.0, 5.0, 20.0, 25.0, 30.0, 25.0] () +CROP_START_DATE: 2010-05-14 () +TSUM2: 900 () +cropname: alfalfa () +AMAXTB: [ 0. 0.84147098 0.90929743 0.14112001 -0.7568025 + -0.95892427 -0.2794155 0.6569866 0.98935825 0.41211849 + -0.54402111 -0.99999021] () +TSUM1: 1100 () +classpcse.input.YAMLAgroManagementReader(fname)[source] +Reads PCSE agromanagement files in the YAML format. + +Parameters +: +fname – filename of the agromanagement file. If fname is not provided as a absolute or relative path the file is assumed to be in the current working directory. + +classpcse.input.YAMLCropDataProvider(model=, fpath=None, repository=None, force_reload=False)[source] +A crop data provider for reading crop parameter sets stored in the YAML format. + +param model +: +a model import from pcse.models. This will allow the YAMLCropDataProvider to select the correct branch for each model version. If not provided then pcse.models.Wofost72_PP will be used as default. + +param fpath +: +full path to directory containing YAML files + +param repository +: +URL to repository containg YAML files. This url should be the raw content (e.g. starting with ‘https://raw.githubusercontent.com’) + +param force_reload +: +If set to True, the cache file is ignored and al parameters are reloaded (default False). + +This crop data provider can read and store the parameter sets for multiple crops which is different from most other crop data providers that only can hold data for a single crop. This crop data providers is therefore suitable for running crop rotations with different crop types as the data provider can switch the active crop. + +The most basic use is to call YAMLCropDataProvider with no parameters. It will than pull the crop parameters for WOFOST 7.2 from my github repository at https://github.com/ajwdewit/WOFOST_crop_parameters/tree/wofost72: + +from pcse.input import YAMLCropDataProvider +p = YAMLCropDataProvider() +print(p) +Crop parameters loaded from: https://raw.githubusercontent.com/ajwdewit/WOFOST_crop_parameters/wofost72 +YAMLCropDataProvider - crop and variety not set: no activate crop parameter set! +For a specific model and version just pass the model onto the CropDataProvider:: +from pcse.models import Wofost81_PP +p = YAMLCropDataProvider(Wofost81_PP) +print(p) +Crop parameters loaded from: https://raw.githubusercontent.com/ajwdewit/WOFOST_crop_parameters/wofost81 +Crop and variety not set: no active crop parameter set! +All crops and varieties have been loaded from the YAML file, however no activate crop has been set. Therefore, we need to activate a a particular crop and variety: + +p.set_active_crop('wheat', 'Winter_wheat_101') +print(p) +Crop parameters loaded from: https://raw.githubusercontent.com/ajwdewit/WOFOST_crop_parameters/wofost81 +YAMLCropDataProvider - current active crop 'wheat' with variety 'Winter_wheat_101' +Available crop parameters: + {'DTSMTB': [0.0, 0.0, 30.0, 30.0, 45.0, 30.0], 'NLAI_NPK': 1.0, 'NRESIDLV': 0.004, + 'KCRIT_FR': 1.0, 'RDRLV_NPK': 0.05, 'TCPT': 10, 'DEPNR': 4.5, 'KMAXRT_FR': 0.5, + ... + ... + 'TSUM2': 1194, 'TSUM1': 543, 'TSUMEM': 120} +Additionally, it is possible to load YAML parameter files from your local file system: + +p = YAMLCropDataProvider(fpath=r"D:\UserData\sources\WOFOST_crop_parameters") +print(p) +YAMLCropDataProvider - crop and variety not set: no activate crop parameter set! +Finally, it is possible to pull data from your fork of my github repository by specifying the URL to that repository: + +p = YAMLCropDataProvider(repository="https://raw.githubusercontent.com//WOFOST_crop_parameters//") +To increase performance of loading parameters, the YAMLCropDataProvider will create a cache file that can be restored much quicker compared to loading the YAML files. When reading YAML files from the local file system, care is taken to ensure that the cache file is re-created when updates to the local YAML are made. However, it should be stressed that this is not possible when parameters are retrieved from a URL and there is a risk that parameters are loaded from an outdated cache file. By default, the cache file will be recreated after 7 days, but in order to force an update use force_reload=True to force loading the parameters from the URL. + +classpcse.input.WOFOST72SiteDataProvider(**kwargs)[source] +Site data provider for WOFOST 7.2. + +Site specific parameters for WOFOST 7.2 can be provided through this data provider as well as through a normal python dictionary. The sole purpose of implementing this data provider is that the site parameters for WOFOST are documented, checked and that sensible default values are given. + +The following site specific parameter values can be set through this data provider: + +- IFUNRN Indicates whether non-infiltrating fraction of rain is a function of storm size (1) + or not (0). Default 0 +- NOTINF Maximum fraction of rain not-infiltrating into the soil [0-1], default 0. +- SSMAX Maximum depth of water that can be stored on the soil surface [cm] +- SSI Initial depth of water stored on the surface [cm] +- WAV Initial amount of water in total soil profile [cm] +- SMLIM Initial maximum moisture content in initial rooting depth zone [0-1], default 0.4 +Currently only the value for WAV is mandatory to specify. + +classpcse.input.WOFOST73SiteDataProvider(**kwargs)[source] +Site data provider for WOFOST 7.3 + +Site specific parameters for WOFOST 7.3 can be provided through this data provider as well as through a normal python dictionary. The sole purpose of implementing this data provider is that the site parameters for WOFOST are documented, checked and that sensible default values are given. + +The following site specific parameter values can be set through this data provider: + +- IFUNRN Indicates whether non-infiltrating fraction of rain is a function of storm size (1) + or not (0). Default 0 +- NOTINF Maximum fraction of rain not-infiltrating into the soil [0-1], default 0. +- SSMAX Maximum depth of water that can be stored on the soil surface [cm] +- SSI Initial depth of water stored on the surface [cm] +- WAV Initial amount of water in total soil profile [cm] +- SMLIM Initial maximum moisture content in initial rooting depth zone [0-1], default 0.4 +- CO2 Atmospheric CO2 concentration in ppm +Values for WAV and CO2 is mandatory to specify. + +classpcse.input.WOFOST81SiteDataProvider_Classic(**kwargs)[source] +Site data provider for WOFOST 8.1 for Classic water and nitrogen balance. + +Site specific parameters for WOFOST 8.1 can be provided through this data provider as well as through a normal python dictionary. The sole purpose of implementing this data provider is that the site parameters for WOFOST are documented, checked and that sensible default values are given. + +The following site specific parameter values can be set through this data provider: + +- IFUNRN Indicates whether non-infiltrating fraction of rain is a function of + storm size (1) or not (0). Default 0 +- NOTINF Maximum fraction of rain not-infiltrating into the soil [0-1], + default 0. +- SSMAX Maximum depth of water that can be stored on the soil surface [cm] +- SSI Initial depth of water stored on the surface [cm] +- WAV Initial amount of water in total soil profile [cm] +- SMLIM Initial maximum moisture content in initial rooting depth zone [0-1], + default 0.4 +- CO2 Atmospheric CO2 level (ppm), default 360. +- BG_N_SUPPLY Background N supply through atmospheric deposition in kg/ha/day. Can be + in the order of 25 kg/ha/year in areas with high N pollution. Default 0.0 +- NSOILBASE Base N amount available in the soil. This is often estimated as the nutrient + left over from the previous growth cycle (surplus nutrients, crop residues + or green manure). +- NSOILBASE_FR Daily fraction of soil N coming available through mineralization +- NAVAILI Amount of N available in the pool at initialization of the system [kg/ha] +Currently, the parameters for initial water availability (WAV) and initial availability of nutrients (NAVAILI) are mandatory to specify. + +classpcse.input.WOFOST81SiteDataProvider_SNOMIN(**kwargs)[source] +Site data provider for WOFOST 8.1 for use with the SNOMIN C/N balance. + +The following site specific parameter values can be set through this data provider: + +- IFUNRN Indicates whether non-infiltrating fraction of rain is a function of + storm size (1) or not (0). Default 0 +- NOTINF Maximum fraction of rain not-infiltrating into the soil [0-1], + default 0. +- SSMAX Maximum depth of water that can be stored on the soil surface [cm] +- SSI Initial depth of water stored on the surface [cm] +- WAV Initial amount of water in total soil profile [cm] +- SMLIM Initial maximum moisture content in initial rooting depth zone [0-1], + default 0.4 +- CO2 Atmospheric CO2 level, currently around 400. [ppm] +- A0SOM Initial age of organic material (24.0) [year] +- CNRatioBio C:N ratio of microbial biomass (9.0) [kg C kg-1 N] +- FASDIS Assimilation to dissimilation rate ratio (0.5) [-] +- KDENIT_REF Reference first order rate of denitrification (0.06) [d-1] +- KNIT_REF Reference first order rate of nitrification (1.0) [d-1] +- KSORP Sorption coefficient (0.0005) [m3 soil kg-1 soil] +- MRCDIS Michaelis-Menten constant of relationship organic C-dissimilation rate + and response factor denitrification rate (0.001) [kg C m-2 d-1] +- NH4ConcR NH4-N concentration in rain water (0.9095) [mg NH4+-N L-1 water] +- NO3ConcR NO3-N concentration in rain water (2.1) [mg NO3--N L-1 water] +- NH4I Initial amount of NH4+ per soil layer [kg NH4+ ha-1]. This + should match the number of soil layers specified in the soil + configuration. The initial value can be highly variable and as + high as 300-500 kg/ha of NH4/NO3 if the model was started right + after an N application event. +- NO3I Initial amount of NO3-N per soil layer [kg NO3-N ha-1]. This + should match the number of soil layers specified in the soil + configuration. The initial value can be highly variable and as + high as 300-500 kg/ha of NH4/NO3 if the model was started right + after an N application event. +- WFPS_CRIT Critical fraction water filled soil pores (0.8) [m3 water m-3 pores] + + +*important*: Some of the valid ranges of parameters for WOFOST 8.1/SNOMIN are uncertain +and therefore values outside of the specified ranges here may be valid in certain cases. +Simple or dummy data providers +This class of data providers can be used to provide parameter values in cases where separate files or a database is not needed or not practical. An example is the set of soil parameters for simulation of potential production conditions where the value of the parameters does not matter but nevertheless some values must be provided to the model. + +classpcse.util.DummySoilDataProvider[source] +This class is to provide some dummy soil parameters for potential production simulation. + +Simulation of potential production levels is independent of the soil. Nevertheless, the model does not some parameter values. This data provider provides some hard coded parameter values for this situation. + +The database tools +Note + +The dataproviders for CGMS database were removed from PCSE starting with version 6.0.10 because they were forcing a dependency on PCSE (SQLAlchemy) which was generating problems with other packages. Moreover SQLAlchemy is not required for running PCSE and the DB tools were not broadly used anyway. + +The database tools contain functions and classes for retrieving agromanagement, parameter values and weather variables from database structures implemented for different versions of the European Crop Growth Monitoring System. + +Note that the data providers only provide functionality for reading data, there are no tools here writing simulation results to a CGMS database. This was done on purpose as writing data can be a complex matter and it is our experience that this can be done more easily with dedicated database loader tools such as SQLLoader for ORACLE or the load data infile syntax of MySQL. + +Convenience routines +These routines are there for conveniently starting a WOFOST simulation for the demonstration and tutorials. They can serve as an example to build your own script but have no further relevance. + +pcse.start_wofost.start_wofost(grid=31031, crop=1, year=2000, mode='wlp')[source] +Provides a convenient interface for starting a WOFOST instance for the internal Demo DB. + +If started with no arguments, the routine will connnect to the demo database and initialize WOFOST for winter-wheat (cropno=1) in Spain (grid_no=31031) for the year 2000 in water-limited production (mode=’wlp’) + +Parameters +: +grid – grid number, defaults to 31031 + +crop – crop number, defaults to 1 (winter-wheat in the demo database) + +year – year to start, defaults to 2000 + +mode – production mode (‘pp’ or ‘wlp’), defaults to ‘wlp’ + +example: + +import pcse +wofsim = pcse.start_wofost(grid=31031, crop=1, year=2000, + mode='wlp') + +wofsim + +wofsim.run(days=300) +wofsim.get_variable('tagp') +15261.752187075261 +Miscelaneous utilities +Many miscelaneous function for a variety of purposes such as the Arbitrary Function Generator (AfGen) for linear interpolation and functions for calculating Penman Penman/Monteith reference evapotranspiration, the Angstrom equation and astronomical calculations such as day length. + +pcse.util.reference_ET(DAY, LAT, ELEV, TMIN, TMAX, IRRAD, VAP, WIND, ANGSTA, ANGSTB, ETMODEL='PM', **kwargs)[source] +Calculates reference evapotranspiration values E0, ES0 and ET0. + +The open water (E0) and bare soil evapotranspiration (ES0) are calculated with the modified Penman approach, while the references canopy evapotranspiration is calculated with the modified Penman or the Penman-Monteith approach, the latter is the default. + +Input variables: + +DAY - Python datetime.date object - +LAT - Latitude of the site degrees +ELEV - Elevation above sea level m +TMIN - Minimum temperature C +TMAX - Maximum temperature C +IRRAD - Daily shortwave radiation J m-2 d-1 +VAP - 24-hour average vapour pressure hPa +WIND - 24-hour average windspeed at 2 meter m/s +ANGSTA - Empirical constant in Angstrom formula - +ANGSTB - Empirical constant in Angstrom formula - +ETMODEL - Indicates if the canopy reference ET should PM|P + be calculated with the Penman-Monteith method + (PM) or the modified Penman method (P) +Output is a tuple (E0, ES0, ET0): + +E0 - Penman potential evaporation from a free + water surface [mm/d] +ES0 - Penman potential evaporation from a moist + bare soil surface [mm/d] +ET0 - Penman or Penman-Monteith potential evapotranspiration from a + crop canopy [mm/d] +Note + +The Penman-Monteith algorithm is valid only for a reference canopy, and therefore it is not used to calculate the reference values for bare soil and open water (ES0, E0). + +The background is that the Penman-Monteith model is basically a surface energy balance where the net solar radiation is partitioned over latent and sensible heat fluxes (ignoring the soil heat flux). To estimate this partitioning, the PM method makes a connection between the surface temperature and the air temperature. However, the assumptions underlying the PM model are valid only when the surface where this partitioning takes place is the same for the latent and sensible heat fluxes. + +For a crop canopy this assumption is valid because the leaves of the canopy form the surface where both latent heat flux (through stomata) and sensible heat flux (through leaf temperature) are partitioned. For a soil, this principle does not work because when the soil is drying the evaporation front will quickly disappear below the surface and therefore the assumption that the partitioning surface is the same does not hold anymore. + +For water surfaces, the assumptions underlying PM do not hold because there is no direct relationship between the temperature of the water surface and the net incoming radiation as radiation is absorbed by the water column and the temperature of the water surface is co-determined by other factors (mixing, etc.). Only for a very shallow layer of water (1 cm) the PM methodology could be applied. + +For bare soil and open water the Penman model is preferred. Although it partially suffers from the same problems, it is calibrated somewhat better for open water and bare soil based on its empirical wind function. + +Finally, in crop simulation models the open water evaporation and bare soil evaporation only play a minor role (pre-sowing conditions and flooded rice at early stages), it is not worth investing much effort in improved estimates of reference value for E0 and ES0. + +pcse.util.penman_monteith(DAY, LAT, ELEV, TMIN, TMAX, AVRAD, VAP, WIND2)[source] +Calculates reference ET0 based on the Penman-Monteith model. + +This routine calculates the potential evapotranspiration rate from a reference crop canopy (ET0) in mm/d. For these calculations the analysis by FAO is followed as laid down in the FAO publication Guidelines for computing crop water requirements - FAO Irrigation and drainage paper 56 + +Input variables: + +DAY - Python datetime.date object - +LAT - Latitude of the site degrees +ELEV - Elevation above sea level m +TMIN - Minimum temperature C +TMAX - Maximum temperature C +AVRAD - Daily shortwave radiation J m-2 d-1 +VAP - 24-hour average vapour pressure hPa +WIND2 - 24-hour average windspeed at 2 meter m/s +Output is: + +ET0 - Penman-Monteith potential transpiration +rate from a crop canopy [mm/d] + +pcse.util.penman(DAY, LAT, ELEV, TMIN, TMAX, AVRAD, VAP, WIND2, ANGSTA, ANGSTB)[source] +Calculates E0, ES0, ET0 based on the Penman model. + +This routine calculates the potential evapo(transpi)ration rates from a free water surface (E0), a bare soil surface (ES0), and a crop canopy (ET0) in mm/d. For these calculations the analysis by Penman is followed (Frere and Popov, 1979;Penman, 1948, 1956, and 1963). Subroutines and functions called: ASTRO, LIMIT. + +Input variables: + +DAY - Python datetime.date object - +LAT - Latitude of the site degrees +ELEV - Elevation above sea level m +TMIN - Minimum temperature C +TMAX - Maximum temperature C +AVRAD - Daily shortwave radiation J m-2 d-1 +VAP - 24-hour average vapour pressure hPa +WIND2 - 24-hour average windspeed at 2 meter m/s +ANGSTA - Empirical constant in Angstrom formula - +ANGSTB - Empirical constant in Angstrom formula - +Output is a tuple (E0,ES0,ET0): + +E0 - Penman potential evaporation from a free water surface [mm/d] +ES0 - Penman potential evaporation from a moist bare soil surface [mm/d] +ET0 - Penman potential transpiration from a crop canopy [mm/d] +pcse.util.check_angstromAB(xA, xB)[source] +Routine checks validity of Angstrom coefficients. + +This is the python version of the FORTRAN routine ‘WSCAB’ in ‘weather.for’. + +pcse.util.wind10to2(wind10)[source] +Converts windspeed at 10m to windspeed at 2m using log. wind profile + +pcse.util.angstrom(day, latitude, ssd, cA, cB)[source] +Compute global radiation using the Angstrom equation. + +Global radiation is derived from sunshine duration using the Angstrom equation: globrad = Angot * (cA + cB * (sunshine / daylength) + +Parameters +: +day – day of observation (date object) + +latitude – Latitude of the observation + +ssd – Observed sunshine duration + +cA – Angstrom A parameter + +cB – Angstrom B parameter + +Returns +: +the global radiation in J/m2/day + +pcse.util.doy(day)[source] +Converts a date or datetime object to day-of-year (Jan 1st = doy 1) + +pcse.util.limit(vmin, vmax, v)[source] +limits the range of v between min and max + +pcse.util.daylength(day, latitude, angle=-4, _cache={})[source] +Calculates the daylength for a given day, altitude and base. + +Parameters +: +day – date/datetime object + +latitude – latitude of location + +angle – The photoperiodic daylength starts/ends when the sun is angle degrees under the horizon. Default is -4 degrees. + +Derived from the WOFOST routine ASTRO.FOR and simplified to include only daylength calculation. Results are being cached for performance + +pcse.util.astro(day, latitude, radiation, _cache={})[source] +python version of ASTRO routine by Daniel van Kraalingen. + +This subroutine calculates astronomic daylength, diurnal radiation characteristics such as the atmospheric transmission, diffuse radiation etc. + +Parameters +: +day – date/datetime object + +latitude – latitude of location + +radiation – daily global incoming radiation (J/m2/day) + +output is a namedtuple in the following order and tags: + +DAYL Astronomical daylength (base = 0 degrees) h +DAYLP Astronomical daylength (base =-4 degrees) h +SINLD Seasonal offset of sine of solar height - +COSLD Amplitude of sine of solar height - +DIFPP Diffuse irradiation perpendicular to + direction of light J m-2 s-1 +ATMTR Daily atmospheric transmission - +DSINBE Daily total of effective solar height s +ANGOT Angot radiation at top of atmosphere J m-2 d-1 +Authors: Daniel van Kraalingen Date : April 1991 + +Python version Author : Allard de Wit Date : January 2011 + +pcse.util.merge_dict(d1, d2, overwrite=False)[source] +Merge contents of d1 and d2 and return the merged dictionary + +Note: + +The dictionaries d1 and d2 are unaltered. + +If overwrite=False (default), a RuntimeError will be raised when duplicate keys exist, else any existing keys in d1 are silently overwritten by d2. + +classpcse.util.Afgen(tbl_xy)[source] +Emulates the AFGEN function in WOFOST. + +Parameters +: +tbl_xy – List or array of XY value pairs describing the function the X values should be mononically increasing. + +Returns the interpolated value provided with the absicca value at which the interpolation should take place. + +example: + +tbl_xy = [0,0,1,1,5,10] +f = Afgen(tbl_xy) +f(0.5) +0.5 +f(1.5) +2.125 +f(5) +10.0 +f(6) +10.0 +f(-1) +0.0 +pcse.util.is_a_month(day)[source] +Returns True if the date is on the last day of a month. + +pcse.util.is_a_dekad(day)[source] +Returns True if the date is on a dekad boundary, i.e. the 10th, the 20th or the last day of each month + +pcse.util.is_a_week(day, weekday=0)[source] +Default weekday is Monday. Monday is 0 and Sunday is 6 + +pcse.util.load_SQLite_dump_file(dump_file_name, SQLite_db_name)[source] +Build an SQLite database from dump file . + diff --git a/Modules/Ai/rag/RAG_LOGIC_SIMPLE.md b/Modules/Ai/rag/RAG_LOGIC_SIMPLE.md new file mode 100644 index 0000000..a1155c5 --- /dev/null +++ b/Modules/Ai/rag/RAG_LOGIC_SIMPLE.md @@ -0,0 +1,527 @@ +# توضیح خیلی ساده منطق RAG در پروژه + +این فایل قرار است خیلی ساده بگوید RAG در این پروژه چطور کار می‌کند. + +## اول: RAG یعنی چه؟ + +RAG یعنی: + +1. سوال کاربر را می‌گیریم +2. متن‌های مرتبط را از حافظه دانشی پیدا می‌کنیم +3. آن متن‌ها را کنار سوال می‌گذاریم +4. بعد از مدل زبانی می‌خواهیم جواب بدهد + +یعنی مدل فقط از حافظه خودش جواب نمی‌دهد؛ +قبل از جواب دادن، اطلاعات مرتبط پروژه را هم می‌بیند. + +--- + +## نقش فایل `rag/apps.py` + +فایل `rag/apps.py` فقط اپ Django مربوط به RAG را ثبت می‌کند. + +کار اصلی‌اش این است: + +- اسم اپ را مشخص می‌کند: `rag` +- نام نمایشی اپ را مشخص می‌کند + +پس: + +- `rag/apps.py` منطق اصلی RAG را پیاده‌سازی نمی‌کند +- فقط می‌گوید این اپ در پروژه وجود دارد + +منطق اصلی RAG بیشتر در این فایل‌هاست: + +- `rag/views.py` +- `rag/chat.py` +- `rag/retrieve.py` +- `rag/ingest.py` +- `rag/embedding.py` +- `rag/vector_store.py` +- `rag/user_data.py` +- `rag/config.py` + +--- + +## تصویر خیلی ساده از کل جریان + +RAG در این پروژه دو بخش اصلی دارد: + +### 1) آماده‌سازی دانش + +در این بخش سیستم اطلاعات را جمع می‌کند و داخل دیتابیس برداری ذخیره می‌کند. + +مراحل: + +1. فایل‌های دانش را می‌خواند +2. متن‌ها را خرد می‌کند +3. هر تکه را تبدیل به embedding می‌کند +4. embeddingها را داخل Qdrant ذخیره می‌کند + +این کار بیشتر در `rag/ingest.py` انجام می‌شود. + +### 2) جواب دادن به سوال کاربر + +در این بخش وقتی کاربر سوال می‌پرسد: + +1. سوال embedding می‌شود +2. متن‌های نزدیک و مرتبط پیدا می‌شوند +3. داده‌های کاربر هم اضافه می‌شود +4. همه این‌ها به مدل زبانی داده می‌شود +5. مدل جواب را به صورت stream برمی‌گرداند + +این کار بیشتر در `rag/chat.py` و `rag/retrieve.py` انجام می‌شود. + +--- + +## بخش اول: سیستم چطور دانش را آماده می‌کند؟ + +### فایل اصلی: `rag/ingest.py` + +این فایل کارش این است که اطلاعات را وارد سیستم RAG کند. + +### از کجا اطلاعات می‌آید؟ + +سیستم این منابع را می‌خواند: + +- فایل‌های پایگاه دانش +- فایل لحن یا tone +- داده‌های خاک هر کاربر +- داده‌های هواشناسی هر کاربر + +### پایگاه دانش یعنی چه؟ + +پایگاه دانش یعنی متن‌هایی که پروژه از قبل دارد. + +مثلا: + +- اطلاعات عمومی چت +- اطلاعات آبیاری +- اطلاعات کودهی + +در تنظیمات، برای هر بخش یک knowledge base تعریف شده است. + +--- + +## مرحله 1: خواندن منابع + +تابع `load_sources()` در `rag/ingest.py` منابع را جمع می‌کند. + +خروجی این تابع تقریبا این شکلی است: + +- شناسه منبع +- متن منبع +- شناسه سنسور یا کاربر +- نام پایگاه دانش + +نکته مهم: + +- داده‌های عمومی با `__global__` ذخیره می‌شوند +- داده‌های شخصی هر کاربر با `sensor_uuid` خودش ذخیره می‌شوند +- داده‌های کاربری معمولا با `__all__` در `kb_name` علامت می‌خورند + +این کار باعث می‌شود بعدا سیستم بداند هر متن برای چه کسی یا چه بخشی بوده است. + +--- + +## مرحله 2: خرد کردن متن + +### فایل: `rag/chunker.py` + +متن‌های طولانی مستقیم وارد جستجو نمی‌شوند. +اول آن‌ها را به تکه‌های کوچک‌تر تبدیل می‌کنیم. + +چرا؟ + +چون: + +- جستجو دقیق‌تر می‌شود +- embedding بهتر می‌شود +- مدل فقط بخش‌های لازم را می‌بیند + +مثلا یک فایل بلند به چند chunk تبدیل می‌شود. + +--- + +## مرحله 3: ساخت embedding + +### فایل: `rag/embedding.py` + +هر chunk متنی به یک لیست عددی تبدیل می‌شود. +به این لیست عددی می‌گوییم embedding. + +خیلی ساده: + +- متن شبیه به هم -> embedding شبیه به هم +- متن متفاوت -> embedding متفاوت + +پس بعدا اگر کاربر سوالی شبیه یک متن بپرسد، +سیستم می‌تواند آن متن را پیدا کند. + +--- + +## مرحله 4: ذخیره در Qdrant + +### فایل: `rag/vector_store.py` + +بعد از ساخت embedding، داده‌ها داخل Qdrant ذخیره می‌شوند. + +Qdrant در این پروژه نقش حافظه برداری را دارد. + +برای هر chunk این چیزها ذخیره می‌شود: + +- خود متن +- embedding +- منبع متن +- شماره chunk +- `sensor_uuid` +- `kb_name` + +این metadata خیلی مهم است؛ +چون کمک می‌کند بعدا فقط داده‌های مرتبط برگردند. + +--- + +## دستور ورود اطلاعات + +### فایل: `rag/management/commands/rag_ingest.py` + +این فایل یک command جنگو دارد که ingestion را اجرا می‌کند. + +یعنی اگر این دستور اجرا شود: + +```bash +python manage.py rag_ingest +``` + +سیستم: + +- منابع را می‌خواند +- chunk می‌کند +- embedding می‌سازد +- داخل Qdrant ذخیره می‌کند + +--- + +## بخش دوم: وقتی کاربر سوال می‌پرسد چه می‌شود؟ + +### ورودی API + +### فایل: `rag/views.py` + +در این فایل endpoint چت وجود دارد. + +کارش این است که از کاربر این اطلاعات را بگیرد: + +- `service_id` +- `query` +- `user_id` یا `sensor_uuid` + +بعد چند بررسی انجام می‌شود: + +- آیا سوال خالی نیست؟ +- آیا `service_id` معتبر است؟ +- اگر سرویس نیاز به داده کاربر دارد، آیا `user_id` داده شده؟ + +اگر همه چیز درست باشد، +در نهایت `chat_rag_stream()` صدا زده می‌شود. + +--- + +## `service_id` چرا مهم است؟ + +چون سیستم چند نوع سرویس دارد. + +مثلا: + +- سرویس چت عمومی +- سرویس آبیاری +- سرویس کودهی + +هر سرویس می‌تواند این‌ها را مشخص کند: + +- از کدام knowledge base استفاده شود +- از چه مدل زبانی استفاده شود +- آیا داده‌های شخصی کاربر لازم است یا نه +- چه tone یا system prompt استفاده شود + +این تنظیمات در `rag/config.py` و فایل `config/rag_config.yaml` مدیریت می‌شوند. + +--- + +## ساخت context + +### فایل اصلی: `rag/chat.py` + +مهم‌ترین بخش پاسخ‌گویی همین‌جاست. + +تابع مهم: `build_rag_context()` + +این تابع یک context برای مدل می‌سازد. + +این context از چند بخش ساخته می‌شود: + +1. داده فعلی خاک کاربر +2. داده هواشناسی کاربر +3. متن‌های مرتبط پیدا شده از RAG + +یعنی مدل فقط سوال را نمی‌بیند؛ +بلکه این اطلاعات کمکی را هم می‌بیند. + +--- + +## داده خاک و هواشناسی کاربر از کجا می‌آید؟ + +### فایل: `rag/user_data.py` + +این فایل اطلاعات کاربر را از دیتابیس پروژه می‌سازد. + +دو تابع مهم: + +- `build_user_soil_text(sensor_uuid)` +- `build_user_weather_text(sensor_uuid)` + +کار این توابع: + +- داده‌های مدل‌های پروژه را می‌خوانند +- آن‌ها را به متن ساده تبدیل می‌کنند + +چرا به متن؟ + +چون سیستم RAG در نهایت با متن کار می‌کند. + +پس حتی داده‌های دیتابیس هم به متن تبدیل می‌شوند تا: + +- embed شوند +- یا مستقیم داخل context قرار بگیرند + +--- + +## پیدا کردن متن‌های مرتبط + +### فایل: `rag/retrieve.py` + +در اینجا تابع `search_with_query()` کار اصلی بازیابی را انجام می‌دهد. + +مراحلش ساده است: + +1. سوال کاربر embedding می‌شود +2. یک جستجوی شباهت در Qdrant انجام می‌شود +3. فقط متن‌های مجاز برگردانده می‌شوند + +--- + +## چرا گفتیم "متن‌های مجاز"؟ + +چون این پروژه داده کاربر دارد و نباید اطلاعات یک کاربر به کاربر دیگر برسد. + +برای همین موقع جستجو فیلتر گذاشته می‌شود. + +فیلترها معمولا این‌ها هستند: + +- `sensor_uuid` +- `kb_name` + +یعنی سیستم فقط این‌ها را برمی‌گرداند: + +- داده‌های عمومی (`__global__`) +- داده‌های همان کاربر +- داده‌های همان knowledge base + +پس این بخش برای امنیت و جداسازی اطلاعات خیلی مهم است. + +--- + +## جستجو در Qdrant چطور انجام می‌شود؟ + +### فایل: `rag/vector_store.py` + +تابع `search()` در این فایل: + +- query vector را می‌گیرد +- فیلترها را می‌سازد +- از Qdrant نتیجه می‌گیرد + +بعد نتیجه‌ها را به شکل ساده برمی‌گرداند: + +- `id` +- `score` +- `text` +- `metadata` + +`score` یعنی میزان شباهت. +هرچه بیشتر باشد، یعنی متن به سوال نزدیک‌تر است. + +--- + +## بعد از بازیابی چه می‌شود؟ + +### دوباره در `rag/chat.py` + +بعد از این که متن‌های مرتبط پیدا شدند: + +- متن‌های مرجع جمع می‌شوند +- داده کاربر هم کنار آن‌ها قرار می‌گیرد +- tone و system prompt هم اضافه می‌شود + +در آخر یک پیام system ساخته می‌شود که به مدل می‌گوید: + +- از داده‌های خاک استفاده کن +- از متن‌های مرجع استفاده کن +- با زبان کاربر جواب بده + +--- + +## تولید جواب نهایی + +### تابع: `chat_rag_stream()` + +این تابع: + +1. تنظیمات سرویس را می‌خواند +2. context را می‌سازد +3. پیام system و user را آماده می‌کند +4. به مدل زبانی درخواست می‌فرستد +5. جواب را به صورت stream برمی‌گرداند + +پس جواب نهایی فقط از خود مدل نیست؛ +بلکه از ترکیب این‌ها ساخته می‌شود: + +- سوال کاربر +- داده‌های فعلی کاربر +- متن‌های مرجع RAG +- لحن و دستور سیستم + +--- + +## tone چیست؟ + +tone یعنی لحن پاسخ. + +مثلا سیستم می‌تواند مشخص کند: + +- رسمی جواب بده +- ساده جواب بده +- تخصصی جواب بده + +فایل‌های tone از روی knowledge base یا service خوانده می‌شوند. + +پس tone روی سبک جواب اثر دارد، +نه روی اصل جستجو. + +--- + +## نقش `rag/config.py` + +این فایل تنظیمات را بارگذاری می‌کند. + +مثلا: + +- مدل embedding چیست +- Qdrant کجاست +- اندازه vector چقدر است +- chunking چگونه باشد +- سرویس‌ها چه هستند +- هر سرویس از کدام knowledge base استفاده کند + +یعنی این فایل مغز تنظیمات سیستم است. + +--- + +## خلاصه خیلی ساده کل مسیر + +اگر بخواهیم خیلی خلاصه بگوییم: + +### مرحله آماده‌سازی + +1. فایل‌ها و داده‌های کاربر خوانده می‌شوند +2. متن‌ها chunk می‌شوند +3. embedding ساخته می‌شود +4. داخل Qdrant ذخیره می‌شوند + +### مرحله پاسخ‌گویی + +1. کاربر سوال می‌پرسد +2. سوال embedding می‌شود +3. متن‌های مشابه پیدا می‌شوند +4. داده خاک و هواشناسی کاربر هم اضافه می‌شود +5. همه این‌ها به LLM داده می‌شود +6. LLM جواب نهایی را می‌سازد + +--- + +## فرق این پروژه با یک چت ساده + +اگر چت ساده بود: + +- مدل فقط با دانسته‌های خودش جواب می‌داد + +ولی اینجا: + +- مدل به داده‌های واقعی پروژه دسترسی دارد +- داده‌های همان کاربر را می‌بیند +- از متن‌های مرجع واقعی استفاده می‌کند + +پس جواب‌ها: + +- دقیق‌تر می‌شوند +- شخصی‌تر می‌شوند +- به داده‌های واقعی نزدیک‌تر می‌شوند + +--- + +## فایل‌ها را خیلی ساده به خاطر بسپار + +- `rag/apps.py` -> فقط ثبت اپ +- `rag/views.py` -> گرفتن درخواست کاربر +- `rag/chat.py` -> ساخت context و گرفتن جواب از مدل +- `rag/retrieve.py` -> جستجوی متن مرتبط +- `rag/ingest.py` -> وارد کردن دانش به سیستم +- `rag/embedding.py` -> تبدیل متن به embedding +- `rag/vector_store.py` -> ذخیره و جستجو در Qdrant +- `rag/user_data.py` -> ساخت متن از داده‌های کاربر +- `rag/config.py` -> تنظیمات کل RAG + +--- + +## یک مثال خیلی ساده + +فرض کن کاربر بپرسد: + +`آیا خاک من برای آبیاری مناسب است؟` + +سیستم این کارها را می‌کند: + +1. سوال را می‌گیرد +2. می‌فهمد باید از سرویس یا دانش آبیاری استفاده کند +3. داده خاک همان کاربر را از دیتابیس می‌گیرد +4. داده هواشناسی را هم می‌گیرد +5. متن‌های مرتبط آبیاری را از Qdrant پیدا می‌کند +6. همه را به مدل می‌دهد +7. مدل جواب می‌دهد + +پس جواب نهایی فقط یک حدس عمومی نیست؛ +بلکه بر اساس: + +- اطلاعات خاک +- اطلاعات هوا +- متن‌های مرجع آبیاری + +ساخته می‌شود. + +--- + +## نتیجه نهایی + +منطق RAG این پروژه به زبان خیلی ساده این است: + +- اول دانش را آماده می‌کند +- بعد موقع سوال، دانش مرتبط را پیدا می‌کند +- داده‌های واقعی کاربر را هم اضافه می‌کند +- و در آخر از مدل می‌خواهد با این اطلاعات جواب بدهد + +و یادت باشد: + +- `rag/apps.py` فقط فایل ثبت اپ است +- منطق واقعی RAG در فایل‌های `chat`, `retrieve`, `ingest`, `vector_store`, `user_data` و `views` قرار دارد + diff --git a/Modules/Ai/rag/README.md b/Modules/Ai/rag/README.md new file mode 100644 index 0000000..6654355 --- /dev/null +++ b/Modules/Ai/rag/README.md @@ -0,0 +1,393 @@ +# مستند سیستم RAG — پایگاه دانش CropLogic + +## فهرست + +1. [معرفی کلی](#معرفی-کلی) +2. [معماری و ساختار](#معماری-و-ساختار) +3. [منابع داده](#منابع-داده) +4. [پایپ‌لاین Embedding](#پایپلاین-embedding) +5. [نحوه اجرا](#نحوه-اجرا) +6. [فلوی پیام کاربر](#فلوی-پیام-کاربر) +7. [API Endpoint](#api-endpoint) +8. [تنظیمات](#تنظیمات) +9. [ایزوله‌سازی کاربران](#ایزولهسازی-کاربران) +10. [سرویس‌های توصیه](#سرویسهای-توصیه) + +--- + +## معرفی کلی + +سیستم RAG در CropLogic یک چت هوشمند کشاورزی است که: + +- **دانش پایه کشاورزی** را embed و ذخیره می‌کند +- **داده‌های خاک و هواشناسی هر کاربر** را از DB می‌خواند و embed می‌کند +- وقتی کاربر سوال می‌پرسد، **اطلاعات مرتبط** را بازیابی و به **LLM** ارسال می‌کند + +**Vector Store:** Qdrant +**API Provider:** GapGPT (با fallback به Avalai) — Adapter Pattern + +### پایگاه‌های دانش مجزا + +سیستم از **سه پایگاه دانش** مجزا استفاده می‌کند: + +| KB | توضیح | فایل Tone | +|----|-------|-----------| +| `chat` | چت عمومی و پاسخ به سوالات متنوع | `config/tones/chat_tone.txt` | +| `irrigation` | توصیه‌های آبیاری (فرمت JSON) | `config/tones/irrigation_tone.txt` | +| `fertilization` | توصیه‌های کودهی (فرمت JSON) | `config/tones/fertilization_tone.txt` | + +تشخیص هوشمند KB از روی کلمات کلیدی سوال (آبیاری، آب، کود، NPK). + +--- + +## معماری و ساختار + +``` +rag/ +├── config.py # بارگذاری تنظیمات از rag_config.yaml +├── api_provider.py # Adapter Pattern برای GapGPT/Avalai +├── client.py # ساخت کلاینت Qdrant +├── chunker.py # تکه‌تکه کردن متن +├── embedding.py # تعبیه‌سازی متن +├── vector_store.py # ذخیره و جستجو در Qdrant (با فیلتر kb_name) +├── user_data.py # خواندن داده‌های خاک/سنسور/هواشناسی از DB +├── ingest.py # پایپ‌لاین: خواندن → چانک → embed → ذخیره +├── retrieve.py # بازیابی: embed کوئری → جستجو +├── chat.py # ساخت context و چت استریمی با LLM +├── views.py # API endpoint +├── urls.py # مسیریابی +├── tasks.py # تسک Celery +├── services/ # سرویس‌های توصیه (بدون API) +│ ├── irrigation.py # توصیه آبیاری +│ └── fertilization.py # توصیه کودهی +└── management/commands/ + └── rag_ingest.py +``` + +فایل‌های تنظیمات: + +``` +config/ +├── rag_config.yaml +├── tones/ +│ ├── chat_tone.txt +│ ├── irrigation_tone.txt +│ └── fertilization_tone.txt +└── knowledge_base/ + ├── chat/ + ├── irrigation/ + └── fertilization/ +``` + +--- + +## منابع داده + +سیستم از **چهار منبع** داده تغذیه می‌شود: + +### 1. لحن‌های مجزا — `config/tones/` + +هر KB یک فایل لحن مخصوص دارد که سبک خروجی LLM را تعریف می‌کند. + +ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization` + +### 2. پایگاه‌های دانش — `config/knowledge_base/` + +- `chat/`: دانش عمومی کشاورزی +- `irrigation/`: دانش تخصصی آبیاری (ET0، بارش، رطوبت) +- `fertilization/`: دانش تخصصی کودهی (NPK، pH، نوع خاک) + +ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization` + +### 3. داده‌های خاک کاربر — از DB + +برای هر سنسور: +- `SensorData`: رطوبت، دما، pH، EC، NPK +- `SoilLocation`: مختصات جغرافیایی +- `SoilDepthData`: داده‌های خاک در سه عمق + +تابع `build_user_soil_text()` این داده‌ها را به متن فارسی تبدیل می‌کند. + +ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__` + +### 4. داده‌های هواشناسی کاربر — از DB + +- `WeatherForecast`: پیش‌بینی ۷ روز آینده (دما، بارش، رطوبت، باد، ET0) + +تابع `build_user_weather_text()` این داده‌ها را به متن فارسی تبدیل می‌کند. + +ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__` + +--- + +## پایپلاین Embedding + +``` +منابع → load_sources() → chunk_text() → embed_texts() → Qdrant +``` + +1. **بارگذاری منابع** (`ingest.py:load_sources`): + - لحن‌ها از `config/tones/` + - KB‌ها از `config/knowledge_base/` + - داده‌های کاربران از DB (`user_data.py`) + +2. **چانک کردن** (`chunker.py`): + - حداکثر ۵۰۰ توکن هر چانک + - ۵۰ توکن همپوشانی + +3. **Embedding** (`embedding.py`): + - استفاده از `api_provider.get_embedding_client()` + - مدل: `text-embedding-3-small` + - بچ‌سایز: ۳۲ + +4. **ذخیره در Qdrant** (`vector_store.py`): + - هر point: `{id, vector[1536], payload{text, source, sensor_uuid, kb_name, chunk_index}}` + +--- + +## نحوه اجرا + +### دستی + +```bash +python manage.py rag_ingest --recreate +``` + +### دوره‌ای (Celery Beat) + +تسک `rag_ingest_task` هر ۶ ساعت اجرا می‌شود و داده‌های جدید را embed می‌کند. + +--- + +## فلوی پیام کاربر + +``` +POST /api/rag/chat/ {message, sensor_uuid} + ↓ +1. تشخیص KB از روی کلمات کلیدی (_detect_kb_intent) + ↓ +2. بارگذاری داده‌های فعلی کاربر از DB: + - build_user_soil_text(sensor_uuid) + - build_user_weather_text(sensor_uuid) + ↓ +3. Embed کردن سوال (embed_single) + ↓ +4. جستجو در Qdrant با فیلتر: + - sensor_uuid = {uuid کاربر} OR __global__ + - kb_name = {detected_kb} OR __all__ + ↓ +5. ساخت context: + [داده‌های فعلی خاک] + [پیش‌بینی هواشناسی] + [متن‌های مرجع از RAG] + ↓ +6. ارسال به LLM (GapGPT): + system_prompt = tone + دستورالعمل + context + ↓ +7. StreamingHttpResponse → کاربر +``` + +--- + +## API Endpoint + +### POST `/api/rag/chat/` + +**Request:** +```json +{ + "message": "وضعیت خاک من چطوره؟", + "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Response:** Stream متنی (text/plain) + +--- + +## تنظیمات + +### `config/rag_config.yaml` + +```yaml +embedding: + provider: "gapgpt" # gapgpt یا avalai + model: "text-embedding-3-small" + 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" + +qdrant: + host: "localhost" + port: 6333 + collection_name: "croplogic_kb" + vector_size: 1536 + +chunking: + max_chunk_tokens: 500 + overlap_tokens: 50 + +llm: + 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" + irrigation: + path: "config/knowledge_base/irrigation" + tone_file: "config/tones/irrigation_tone.txt" + fertilization: + path: "config/knowledge_base/fertilization" + tone_file: "config/tones/fertilization_tone.txt" +``` + +### متغیرهای محیطی + +| متغیر | توضیح | +|-------|-------| +| `GAPGPT_API_KEY` | کلید API برای GapGPT | +| `AVALAI_API_KEY` | کلید API برای Avalai (fallback) | +| `QDRANT_HOST` | آدرس Qdrant | +| `QDRANT_PORT` | پورت Qdrant | + +--- + +## ایزوله‌سازی کاربران + +- هر چانک یک فیلد `sensor_uuid` در metadata دارد +- داده‌های عمومی: `sensor_uuid = __global__` +- داده‌های کاربر: `sensor_uuid = {uuid واقعی}` +- هنگام جستجو، فیلتر `should` اعمال می‌شود: + - `sensor_uuid = {uuid کاربر}` OR `__global__` + - `kb_name = {detected_kb}` OR `__all__` +- نتیجه: هر کاربر فقط داده‌های خودش + دانش عمومی را می‌بیند + +--- + +## سرویس‌های توصیه + +سرویس‌های آبیاری و کودهی **بدون API** هستند و از RAG استفاده می‌کنند. + +### توصیه آبیاری + +```python +from rag.services import get_irrigation_recommendation + +result = get_irrigation_recommendation( + sensor_uuid="550e8400-...", + query="توصیه آبیاری برای مزرعه من چیست؟" # اختیاری +) +``` + +**خروجی:** +```python +{ + "irrigation_needed": True, + "amount_mm": 25.0, + "reason": "رطوبت خاک پایین و بارش پیش‌بینی نشده", + "next_check_date": "2026-03-20", + "raw_response": "..." +} +``` + +### توصیه کودهی + +```python +from rag.services import get_fertilization_recommendation + +result = get_fertilization_recommendation( + sensor_uuid="550e8400-...", + query="توصیه کودهی برای مزرعه من چیست؟" # اختیاری +) +``` + +**خروجی:** +```python +{ + "fertilizer_needed": True, + "fertilizer_type": "NPK 20-10-10", + "amount_kg_per_hectare": 150.0, + "reason": "سطح ازت پایین", + "npk_status": { + "nitrogen": "low", + "phosphorus": "normal", + "potassium": "normal" + }, + "raw_response": "..." +} +``` + +--- + +## نمودار معماری + +``` +┌─────────────────────────────────────────────────────────┐ +│ منابع داده │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ tones/ │ │ knowledge_ │ │ Django DB │ │ +│ │ 3 files │ │ base/ │ │ SensorData │ │ +│ │ │ │ chat/irrig/ │ │ SoilLocation │ │ +│ │ │ │ fertiliz/ │ │ SoilDepthData │ │ +│ │ │ │ │ │ WeatherForecast │ │ +│ └────┬─────┘ └──────┬───────┘ └────────┬──────────┘ │ +│ │ │ │ │ +│ └───────────┬────┘ │ │ +│ __global__ sensor_uuid │ +│ kb_name=chat/ kb_name=__all__ │ +│ irrigation/ │ +│ fertilization │ +└───────────────┬────────────────────────┬────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ ingest pipeline │ +│ │ +│ load_sources() → chunk_text() → embed_texts() │ +│ (با Adapter Pattern: GapGPT/Avalai) │ +│ │ +│ کامند: python manage.py rag_ingest --recreate │ +│ تسک: rag_ingest_task.delay(recreate=True) │ +└────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Qdrant │ +│ collection: croplogic_kb │ +│ │ +│ هر point = {id, vector[1536], payload{text, │ +│ source, sensor_uuid, kb_name, │ +│ chunk_index}} │ +└────────────────────────┬────────────────────────────────┘ + │ + (هنگام سوال کاربر) + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ فلوی پاسخ به کاربر │ +│ │ +│ 1. POST /api/rag/chat/ {message, sensor_uuid} │ +│ 2. تشخیص KB از کلمات کلیدی (_detect_kb_intent) │ +│ 3. build_user_soil_text() + build_user_weather_text() │ +│ 4. embed_single(message) → query vector │ +│ 5. Qdrant search با فیلتر sensor_uuid + kb_name │ +│ 6. system_prompt = tone + دستورالعمل + context │ +│ 7. GapGPT LLM (gpt-4o) → streaming response │ +│ 8. StreamingHttpResponse → کاربر │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +**تغییرات اخیر:** + +- ✅ Adapter Pattern برای سوئیچ بین GapGPT و Avalai +- ✅ سه پایگاه دانش مجزا (chat/irrigation/fertilization) +- ✅ داده‌های هواشناسی embed می‌شوند +- ✅ فیلتر `kb_name` در جستجوی Qdrant +- ✅ سرویس‌های توصیه آبیاری و کودهی (بدون API) diff --git a/Modules/Ai/rag/__init__.py b/Modules/Ai/rag/__init__.py new file mode 100644 index 0000000..8f88950 --- /dev/null +++ b/Modules/Ai/rag/__init__.py @@ -0,0 +1,5 @@ +""" +ماژول RAG — برای جلوگیری از AppRegistryNotReady این فایل import سنگین انجام نمی‌دهد. +""" + +__all__: list[str] = [] diff --git a/Modules/Ai/rag/api_provider.py b/Modules/Ai/rag/api_provider.py new file mode 100644 index 0000000..561a192 --- /dev/null +++ b/Modules/Ai/rag/api_provider.py @@ -0,0 +1,92 @@ +""" +Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و ArvanCloud AI. +""" +import logging +import os + +try: + from openai import OpenAI +except ImportError: # pragma: no cover - optional for stripped test envs + class OpenAI: # type: ignore[override] + def __init__(self, *args, **kwargs): + raise ImportError("openai package is required for RAG clients.") + +from .config import RAGConfig, load_rag_config + +logger = logging.getLogger(__name__) + + +def _mask_secret(value: str | None) -> str: + if not value: + return "" + if len(value) <= 8: + return "****" + return f"{value[:4]}...{value[-4:]}" + + +def _get_env_or_value(env_var: str | None, direct_value: str | None) -> str | None: + if env_var: + return os.environ.get(env_var) or direct_value + return direct_value + + +def get_embedding_client(config: RAGConfig | None = None) -> OpenAI: + """ + ساخت کلاینت OpenAI برای Embedding بر اساس provider فعال. + provider از config.embedding.provider خوانده می‌شود. + """ + cfg = config or load_rag_config() + emb = cfg.embedding + provider = emb.provider or "gapgpt" + logger.info("embedding provider=%s", provider) + + if provider == "avalai": + env_var = emb.avalai_api_key_env or emb.api_key_env or "AVALAI_API_KEY" + api_key = _get_env_or_value(env_var, emb.avalai_api_key or emb.api_key) + base_url = emb.avalai_base_url or emb.base_url or "https://api.avalai.ir/v1" + elif provider == "arvancloud": + env_var = emb.arvancloud_api_key_env or "ARVANCLOUD_EMBEDDING_API_KEY" + api_key = _get_env_or_value(env_var, emb.arvancloud_api_key) + base_url = ( + emb.arvancloud_base_url + or "https://arvancloudai.ir/gateway/models/Bge-m3/rBA2PgcTC2sfhXwamupI4NvQ8crddUGTYXOsuKVye91PoNuGhbRgpHHNY8sMHBVQWWerZSAi4a0AijUL6YBqY9EW-Y1LhW_0ec6Mxr85GQy41lXiV6M8Od4mvLIeDF-wLRUHIervod0O5ZqGj2MOX8z1zdUpXkCrIS2uDjHlfHBZofledZjsOVDmFZU7IYfvkA__ljQqNeKXSFgpwUR7SmsbRUXGTDB2moLdeRq9zBpQIw/v1" + ) + else: + env_var = emb.api_key_env or "GAPGPT_API_KEY" + api_key = _get_env_or_value(env_var, emb.api_key) + base_url = emb.base_url or "https://api.gapgpt.app/v1" + logger.info( + "embedding base_url=%s api_key=%s", + base_url, + _mask_secret(api_key), + ) + + return OpenAI(api_key=api_key, base_url=base_url) + + +def get_chat_client(config: RAGConfig | None = None) -> OpenAI: + """ + ساخت کلاینت OpenAI برای Chat/LLM بر اساس provider فعال. + provider از config.llm.provider خوانده می‌شود. + """ + cfg = config or load_rag_config() + llm = cfg.llm + provider = llm.provider or cfg.embedding.provider + + + logger.info("chat provider=%s", provider) + if provider == "avalai": + env_var = llm.avalai_api_key_env or llm.api_key_env or "AVALAI_API_KEY" + api_key = os.environ.get(env_var) + base_url = llm.avalai_base_url or llm.base_url or "https://api.avalai.ir/v1" + else: + env_var = llm.api_key_env or "GAPGPT_API_KEY" + api_key = os.environ.get(env_var) + base_url = llm.base_url or "https://api.gapgpt.app/v1" + logger.info( + "chat base_url=%s api_key=%s", + base_url, + _mask_secret(api_key), + ) + + return OpenAI(api_key=api_key, base_url=base_url) diff --git a/Modules/Ai/rag/apps.py b/Modules/Ai/rag/apps.py new file mode 100644 index 0000000..b23956e --- /dev/null +++ b/Modules/Ai/rag/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RagConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "rag" + verbose_name = "RAG - پایگاه دانش" diff --git a/Modules/Ai/rag/chat.py b/Modules/Ai/rag/chat.py new file mode 100644 index 0000000..4fbbd57 --- /dev/null +++ b/Modules/Ai/rag/chat.py @@ -0,0 +1,434 @@ +""" +چت RAG برای API چت عمومی — با ارسال کامل داده مزرعه و retrieval تکمیلی از KB. +""" +import base64 +import json +import logging +import mimetypes +from pathlib import Path +from typing import Any + +from .api_provider import get_chat_client +from .chunker import chunk_text +from .config import RAGConfig, ServiceConfig, get_service_config, load_rag_config +from .retrieve import search_with_texts + +logger = logging.getLogger(__name__) + + +def _coerce_text_content(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, dict) and item.get("type") == "text": + text_value = item.get("text") + if isinstance(text_value, str) and text_value.strip(): + parts.append(text_value.strip()) + elif isinstance(item, str) and item.strip(): + parts.append(item.strip()) + return "\n".join(parts) + return str(value) + + +def _normalize_image_inputs(images: list[Any] | None) -> list[dict[str, str]]: + normalized: list[dict[str, str]] = [] + for item in images or []: + if isinstance(item, str): + value = item.strip() + if value: + normalized.append({"url": value}) + continue + if not isinstance(item, dict): + continue + url = item.get("url") or item.get("image_url") or item.get("data_url") + if not isinstance(url, str) or not url.strip(): + continue + entry = {"url": url.strip()} + detail = item.get("detail") + if isinstance(detail, str) and detail.strip(): + entry["detail"] = detail.strip() + normalized.append(entry) + return normalized + + +def _build_content_parts(text: str, images: list[dict[str, str]] | None = None) -> str | list[dict[str, Any]]: + normalized_text = (text or "").strip() + normalized_images = _normalize_image_inputs(images) + if not normalized_images: + return normalized_text + + parts: list[dict[str, Any]] = [] + if normalized_text: + parts.append({"type": "text", "text": normalized_text}) + for image in normalized_images: + image_payload: dict[str, Any] = {"url": image["url"]} + if image.get("detail"): + image_payload["detail"] = image["detail"] + parts.append({"type": "image_url", "image_url": image_payload}) + return parts + + +def _normalize_history_messages(history: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + for item in history or []: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip().lower() + if role not in {"user", "assistant"}: + continue + text = _coerce_text_content( + item.get("content", item.get("message", item.get("text"))) + ).strip() + images = _normalize_image_inputs(item.get("images") or item.get("image_urls")) + if not text and not images: + continue + content = _build_content_parts(text, images if role == "user" else None) + normalized.append({"role": role, "content": content}) + return normalized + + +def encode_uploaded_image(uploaded_file: Any) -> dict[str, str]: + content_type = getattr(uploaded_file, "content_type", None) or mimetypes.guess_type( + getattr(uploaded_file, "name", "") + )[0] or "application/octet-stream" + raw = uploaded_file.read() + if not isinstance(raw, (bytes, bytearray)): + raise ValueError("Uploaded image payload is invalid.") + encoded = base64.b64encode(raw).decode("ascii") + return { + "url": f"data:{content_type};base64,{encoded}", + "detail": "auto", + } + + +def _load_tone(config: RAGConfig | None) -> str: + """بارگذاری فایل لحن پیش‌فرض (chat KB).""" + cfg = config or load_rag_config() + base = Path(__file__).resolve().parent.parent + chat_kb = cfg.knowledge_bases.get("chat") + if chat_kb: + tone_path = base / chat_kb.tone_file + if tone_path.exists(): + return tone_path.read_text(encoding="utf-8").strip() + logger.warning("Default tone file not found: %s", tone_path) + return "" + + +def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str: + cfg = config or load_rag_config() + if service.tone_file: + base = Path(__file__).resolve().parent.parent + tone_path = base / service.tone_file + if tone_path.exists(): + return tone_path.read_text(encoding="utf-8").strip() + logger.warning("Service tone file not found: %s", tone_path) + return _load_tone(cfg) + + +def _format_farm_context(farm_uuid: str) -> str: + from farm_data.services import get_farm_details + + farm_details = get_farm_details(farm_uuid) + if not farm_details: + raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") + + serialized = json.dumps( + farm_details, + ensure_ascii=False, + indent=2, + default=str, + ) + return "[اطلاعات کامل مزرعه]\n" + serialized + + +def _format_farm_context_from_details(farm_details: dict) -> str: + serialized = json.dumps( + farm_details, + ensure_ascii=False, + indent=2, + default=str, + ) + return "[اطلاعات کامل مزرعه]\n" + serialized + + +def _load_farm_details_context( + sensor_uuid: str | None, + farm_details: dict | None = None, +) -> str: + if not sensor_uuid: + return "" + if farm_details is not None: + return _format_farm_context_from_details(farm_details) + return _format_farm_context(sensor_uuid) + + +def _build_system_prompt( + service: ServiceConfig, + query: str, + farm_context: str, + config: RAGConfig | None = None, +) -> str: + tone = _load_service_tone(service, config) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append( + "با استفاده از اطلاعات کامل مزرعه و اطلاعات بازیابی‌شده از پایگاه دانش که در ادامه آمده " + "به سوال کاربر پاسخ بده. " + "اگر داده‌ای در اطلاعات مزرعه وجود دارد، همان را مبنای پاسخ قرار بده و چیزی حدس نزن. " + "نتایج بازیابی‌شده از پایگاه دانش را برای تکمیل یا توضیح پاسخ استفاده کن. " + "اگر داده کافی نبود، این کمبود را شفاف بگو. " + "پاسخ را به زبان کاربر بنویس." + ) + system_parts.append(farm_context) + system_parts.append(f"[سوال کاربر]\n{query}") + return "\n\n".join(part for part in system_parts if part) + + +def _create_audit_log( + farm_uuid: str, + service_id: str, + model: str, + query: str, + system_prompt: str, + messages: list[dict], +) -> "ChatAuditLog": + from .models import ChatAuditLog + + log = ChatAuditLog.objects.create( + farm_uuid=farm_uuid, + service_id=service_id, + model=model, + user_query=query, + system_prompt=system_prompt, + messages=messages, + status=ChatAuditLog.STATUS_STARTED, + ) + logger.info( + "Created chat audit log id=%s service_id=%s farm_uuid=%s model=%s", + log.id, + service_id, + farm_uuid, + model, + ) + return log + + +def _complete_audit_log(audit_log: "ChatAuditLog", response_text: str) -> None: + from .models import ChatAuditLog + + audit_log.response_text = response_text + audit_log.status = ChatAuditLog.STATUS_COMPLETED + audit_log.save(update_fields=["response_text", "status", "updated_at"]) + + +def _fail_audit_log( + audit_log: "ChatAuditLog", + error_message: str, + response_text: str = "", +) -> None: + from .models import ChatAuditLog + + audit_log.response_text = response_text + audit_log.error_message = error_message + audit_log.status = ChatAuditLog.STATUS_FAILED + audit_log.save( + update_fields=["response_text", "error_message", "status", "updated_at"] + ) + + +def build_rag_context( + query: str, + sensor_uuid: str | None = None, + config: RAGConfig | None = None, + limit: int = 8, + kb_name: str | None = None, + service_id: str | None = None, + farm_details: dict | None = None, +) -> str: + """ + ساخت context مشترک برای همه سرویس‌های RAG. + شامل: + - اطلاعات کامل مزرعه از farm_data/services.py + - جستجوی KB بر اساس پیام کاربر + - جستجوی KB بر اساس chunk های کامل داده مزرعه + """ + + logger.info( + "Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s", + sensor_uuid, + kb_name, + limit, + len(query or ""), + ) + parts: list[str] = [] + cfg = config or load_rag_config() + service = get_service_config(service_id, cfg) if service_id else None + include_user_embeddings = service.use_user_embeddings if service else True + resolved_kb_name = kb_name or (service.knowledge_base if service else None) + farm_context = _load_farm_details_context( + sensor_uuid=sensor_uuid, + farm_details=farm_details, + ) + + if farm_context: + parts.append(farm_context) + + search_texts = [query] + if farm_context: + search_texts.extend(chunk_text(farm_context, config=cfg)) + + results = search_with_texts( + search_texts, + sensor_uuid=sensor_uuid, + limit=limit, + per_text_limit=3, + config=cfg, + kb_name=resolved_kb_name, + service_id=service_id, + use_user_embeddings=include_user_embeddings, + ) + if results: + rag_texts = [r.get("text", "").strip() for r in results if r.get("text")] + if rag_texts: + parts.append("[متن‌های مرجع]\n" + "\n\n---\n\n".join(rag_texts)) + + return "\n\n---\n\n".join(parts) if parts else "" + + +def chat_rag_stream( + query: str, + farm_uuid: str, + config: RAGConfig | None = None, + system_override: str | None = None, + farm_details: dict | None = None, + history: list[dict[str, Any]] | None = None, + images: list[dict[str, str]] | None = None, +): + """ + چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه. + + Args: + query: پیام کاربر + farm_uuid: شناسه مزرعه + config: تنظیمات RAG + system_override: جایگزین system prompt (اختیاری) + history: لیست پیام های قبلی کاربر/هوش مصنوعی + images: تصاویر مربوط به پیام فعلی کاربر + + Yields: + chunk های استریم پاسخ مدل + """ + cfg = config or load_rag_config() + service_id = "chat" + service = get_service_config(service_id, cfg) + service_llm_config = service.llm + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service_llm_config, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service_llm_config.model + + logger.info( + "chat_rag_stream started service_id=%s farm_uuid=%s query_len=%s", + service_id, + farm_uuid, + len(query or ""), + ) + + context = build_rag_context( + query=query, + sensor_uuid=farm_uuid, + config=cfg, + service_id=service_id, + farm_details=farm_details, + ) + logger.info( + "Loaded augmented context for farm_uuid=%s context_len=%s", + farm_uuid, + len(context), + ) + + if system_override is not None: + system_prompt = system_override + else: + system_prompt = _build_system_prompt( + service, + query, + context, + config=cfg, + ) + + messages = [{"role": "system", "content": system_prompt}] + messages.extend(_normalize_history_messages(history)) + messages.append({"role": "user", "content": _build_content_parts(query, images)}) + + logger.info( + "Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s", + service_id, + farm_uuid, + model, + len(messages), + ) + logger.info("Final system prompt for farm_uuid=%s:\n%s", farm_uuid, system_prompt) + + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=service_id, + model=model, + query=query, + system_prompt=system_prompt, + messages=messages, + ) + + response_chunks: list[str] = [] + try: + stream = client.chat.completions.create( + model=model, + messages=messages, + stream=True, + ) + logger.info( + "Started streaming response id=%s service_id=%s farm_uuid=%s", + audit_log.id, + service_id, + farm_uuid, + ) + + for chunk in stream: + delta = chunk.choices[0].delta if chunk.choices else None + content = delta.content if delta else "" + if content: + response_chunks.append(content) + yield content + + full_response = "".join(response_chunks) + _complete_audit_log(audit_log, full_response) + logger.info( + "Completed chat response id=%s farm_uuid=%s response_len=%s response=\n%s", + audit_log.id, + farm_uuid, + len(full_response), + full_response, + ) + except Exception as exc: + partial_response = "".join(response_chunks) + _fail_audit_log(audit_log, str(exc), partial_response) + logger.exception( + "Chat request failed id=%s service_id=%s farm_uuid=%s partial_response_len=%s", + audit_log.id, + service_id, + farm_uuid, + len(partial_response), + ) + raise diff --git a/Modules/Ai/rag/chunker.py b/Modules/Ai/rag/chunker.py new file mode 100644 index 0000000..78a66c8 --- /dev/null +++ b/Modules/Ai/rag/chunker.py @@ -0,0 +1,65 @@ +""" +تکه‌تکه کردن متن (Chunking) برای RAG +""" +from .config import load_rag_config, RAGConfig + + +# تقریب: هر توکن حدود ۳–۴ نویسه برای فارسی/انگلیسی +CHARS_PER_TOKEN = 3.5 + + +def chunk_text( + text: str, + config: RAGConfig | None = None, + max_chunk_tokens: int | None = None, + overlap_tokens: int | None = None, +) -> list[str]: + """ + تکه‌تکه کردن متن بر اساس توکن (تقریبی با نویسه). + + Args: + text: متن ورودی + config: تنظیمات RAG + max_chunk_tokens: حداکثر توکن هر چانک (override) + overlap_tokens: تعداد توکن همپوشانی بین چانک‌ها (override) + + Returns: + لیست چانک‌ها + """ + cfg = config or load_rag_config() + max_tok = max_chunk_tokens if max_chunk_tokens is not None else cfg.chunking.max_chunk_tokens + overlap = overlap_tokens if overlap_tokens is not None else cfg.chunking.overlap_tokens + + max_chars = int(max_tok * CHARS_PER_TOKEN) + overlap_chars = int(overlap * CHARS_PER_TOKEN) + step = max_chars - overlap_chars + + if step <= 0: + step = max_chars + + text = text.strip() + if not text: + return [] + + chunks: list[str] = [] + start = 0 + while start < len(text): + end = start + max_chars + chunk = text[start:end].strip() + if chunk: + chunks.append(chunk) + start += step + + return chunks + + +def chunk_texts( + texts: list[str], + config: RAGConfig | None = None, + **kwargs, +) -> list[str]: + """چند متن را تکه‌تکه می‌کند و همه چانک‌ها را برمی‌گرداند.""" + all_chunks: list[str] = [] + for t in texts: + all_chunks.extend(chunk_text(t, config=config, **kwargs)) + return all_chunks diff --git a/Modules/Ai/rag/client.py b/Modules/Ai/rag/client.py new file mode 100644 index 0000000..b27e5fd --- /dev/null +++ b/Modules/Ai/rag/client.py @@ -0,0 +1,19 @@ +""" +کلاینت Qdrant — اتصال به دیتابیس وکتور +""" +from qdrant_client import QdrantClient +from qdrant_client.http import models as qmodels + +from .config import QdrantConfig, load_rag_config + + +def get_qdrant_client(config: QdrantConfig | None = None) -> QdrantClient: + """ + ایجاد کلاینت Qdrant. + اگر config داده نشود، از rag_config.yaml بارگذاری می‌شود. + """ + if config is None: + rag = load_rag_config() + config = rag.qdrant + + return QdrantClient(host=config.host, port=config.port) diff --git a/Modules/Ai/rag/config.py b/Modules/Ai/rag/config.py new file mode 100644 index 0000000..2a97197 --- /dev/null +++ b/Modules/Ai/rag/config.py @@ -0,0 +1,194 @@ +""" +بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider، +چند پایگاه دانش و چند سرویس. +""" +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +@dataclass +class EmbeddingConfig: + provider: str + model: str + batch_size: int = 32 + api_key: str | None = None + api_key_env: str | None = None + base_url: str | None = None + avalai_api_key: str | None = None + avalai_base_url: str | None = None + avalai_api_key_env: str | None = None + arvancloud_api_key: str | None = None + arvancloud_base_url: str | None = None + arvancloud_api_key_env: str | None = None + + +@dataclass +class QdrantConfig: + host: str = "localhost" + port: int = 6333 + collection_name: str = "croplogic_kb" + vector_size: int = 384 + + +@dataclass +class ChunkingConfig: + max_chunk_tokens: int = 500 + overlap_tokens: int = 50 + + +@dataclass +class LLMConfig: + provider: str = "gapgpt" + model: str = "gpt-4o" + base_url: str | None = None + api_key_env: str | None = None + avalai_base_url: str | None = None + avalai_api_key_env: str | None = None + + +@dataclass +class KnowledgeBaseConfig: + path: str + tone_file: str + description: str = "" + + +@dataclass +class ServiceConfig: + service_id: str + knowledge_base: str + llm: LLMConfig = field(default_factory=LLMConfig) + tone_file: str | None = None + system_prompt: str | None = None + use_user_embeddings: bool = True + description: str = "" + + +@dataclass +class RAGConfig: + embedding: EmbeddingConfig + qdrant: QdrantConfig + chunking: ChunkingConfig + llm: LLMConfig = field(default_factory=LLMConfig) + knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict) + services: dict[str, ServiceConfig] = field(default_factory=dict) + chromadb: dict[str, Any] = field(default_factory=dict) + + +def _build_llm_config(data: dict[str, Any] | None, default: LLMConfig | None = None) -> LLMConfig: + llm_data = data or {} + fallback = default or LLMConfig() + return LLMConfig( + provider=llm_data.get("provider", fallback.provider), + model=llm_data.get("model", fallback.model), + base_url=llm_data.get("base_url", fallback.base_url), + api_key_env=llm_data.get("api_key_env", fallback.api_key_env), + avalai_base_url=llm_data.get("avalai_base_url", fallback.avalai_base_url), + avalai_api_key_env=llm_data.get("avalai_api_key_env", fallback.avalai_api_key_env), + ) + + +def get_service_config(service_id: str, config: RAGConfig | None = None) -> ServiceConfig: + cfg = config or load_rag_config() + service = cfg.services.get(service_id) + if service is None: + raise KeyError(f"Unknown service_id: {service_id}") + return service + + +def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: + """ + بارگذاری تنظیمات از YAML و env. + QDRANT_HOST و QDRANT_PORT از متغیرهای محیطی override می‌شوند. + """ + if config_path is None: + base = Path(__file__).resolve().parent.parent + config_path = base / "config" / "rag_config.yaml" + + path = Path(config_path) + if not path.exists(): + raise FileNotFoundError(f"RAG config not found: {path}") + + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + emb = data.get("embedding", {}) + embedding = EmbeddingConfig( + provider=emb.get("provider", "sentence_transformers"), + model=emb.get("model", "text-embedding-3-small"), + batch_size=emb.get("batch_size", 32), + api_key=emb.get("api_key"), + api_key_env=emb.get("api_key_env"), + base_url=emb.get("base_url"), + avalai_api_key=emb.get("avalai_api_key"), + avalai_base_url=emb.get("avalai_base_url"), + avalai_api_key_env=emb.get("avalai_api_key_env"), + arvancloud_api_key=emb.get("arvancloud_api_key"), + arvancloud_base_url=emb.get("arvancloud_base_url"), + arvancloud_api_key_env=emb.get("arvancloud_api_key_env"), + ) + + qd = data.get("qdrant", {}) + qdrant = QdrantConfig( + host=os.environ.get("QDRANT_HOST", qd.get("host", "localhost")), + port=int(os.environ.get("QDRANT_PORT", qd.get("port", 6333))), + collection_name=qd.get("collection_name", "croplogic_kb"), + vector_size=qd.get("vector_size", 1536), + ) + + ch = data.get("chunking", {}) + chunking = ChunkingConfig( + max_chunk_tokens=ch.get("max_chunk_tokens", 500), + overlap_tokens=ch.get("overlap_tokens", 50), + ) + + llm = _build_llm_config(data.get("llm", {})) + + kb_data = data.get("knowledge_bases", {}) + knowledge_bases: dict[str, KnowledgeBaseConfig] = {} + for kb_name, kb_conf in kb_data.items(): + knowledge_bases[kb_name] = KnowledgeBaseConfig( + path=kb_conf.get("path", f"config/knowledge_base/{kb_name}"), + tone_file=kb_conf.get("tone_file", f"config/tones/{kb_name}_tone.txt"), + description=kb_conf.get("description", ""), + ) + + services_data = data.get("services", {}) + services: dict[str, ServiceConfig] = {} + for service_id, service_conf in services_data.items(): + kb_name = service_conf.get("knowledge_base", service_id) + kb_conf = knowledge_bases.get(kb_name) + services[service_id] = ServiceConfig( + service_id=service_id, + knowledge_base=kb_name, + llm=_build_llm_config(service_conf.get("llm"), default=llm), + tone_file=service_conf.get("tone_file") or (kb_conf.tone_file if kb_conf else None), + system_prompt=service_conf.get("system_prompt"), + use_user_embeddings=service_conf.get("use_user_embeddings", True), + description=service_conf.get("description", ""), + ) + + if not services: + for kb_name, kb_conf in knowledge_bases.items(): + services[kb_name] = ServiceConfig( + service_id=kb_name, + knowledge_base=kb_name, + llm=llm, + tone_file=kb_conf.tone_file, + use_user_embeddings=True, + description=kb_conf.description, + ) + + return RAGConfig( + embedding=embedding, + qdrant=qdrant, + chunking=chunking, + llm=llm, + knowledge_bases=knowledge_bases, + services=services, + chromadb=data.get("chromadb", {}), + ) diff --git a/Modules/Ai/rag/embedding.py b/Modules/Ai/rag/embedding.py new file mode 100644 index 0000000..d0fe719 --- /dev/null +++ b/Modules/Ai/rag/embedding.py @@ -0,0 +1,91 @@ +""" +سرویس تعبیه‌سازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده می‌کند +""" +import logging +import time + +from .api_provider import get_embedding_client +from .config import RAGConfig, load_rag_config +from .observability import classify_exception, log_event, observe_operation, record_metric + +logger = logging.getLogger(__name__) + +def embed_texts( + texts: list[str], + config: RAGConfig | None = None, + model: str | None = None, + dimensions: int | None = None, +) -> list[list[float]]: + """ + تعبیه‌سازی لیست متن‌ها با Avalai. + + Args: + texts: لیست رشته‌های ورودی + config: تنظیمات RAG (پیش‌فرض: load_rag_config) + model: نام مدل (override از config) + dimensions: تعداد ابعاد (فقط برای مدل‌های پشتیبانی‌کننده) + + Returns: + لیست وکتورها + """ + if not texts: + record_metric("rag.embedding.empty_input", operation="embed_texts") + return [] + + cfg = config or load_rag_config() + client = get_embedding_client(cfg) + model_name = model or cfg.embedding.model + provider = cfg.embedding.provider or "unknown" + batch_size = cfg.embedding.batch_size + + all_embeddings: list[list[float]] = [] + extra = {} + if dimensions is not None: + extra["dimensions"] = dimensions + + with observe_operation(source="rag.embedding", provider=provider, operation="embed_texts"): + for i in range(0, len(texts), batch_size): + batch = texts[i : i + batch_size] + started_at = time.monotonic() + try: + resp = client.embeddings.create( + model=model_name, + input=batch, + **extra, + ) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="embedding batch request failed", + source="rag.embedding", + provider=provider, + operation="embed_batch", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code=failure.error_code, + batch_size=len(batch), + model=model_name, + ) + raise + for item in sorted(resp.data, key=lambda x: x.index): + all_embeddings.append(item.embedding) + log_event( + level=logging.INFO, + message="embedding batch request completed", + source="rag.embedding", + provider=provider, + operation="embed_batch", + result_status="success", + duration_ms=(time.monotonic() - started_at) * 1000, + batch_size=len(batch), + model=model_name, + ) + + return all_embeddings + + +def embed_single(text: str, config: RAGConfig | None = None, **kwargs) -> list[float]: + """تعبیه‌سازی یک متن. خروجی مستقیماً یک وکتور است.""" + vecs = embed_texts([text], config=config, **kwargs) + return vecs[0] if vecs else [] diff --git a/Modules/Ai/rag/failure_contract.py b/Modules/Ai/rag/failure_contract.py new file mode 100644 index 0000000..fb7ce1c --- /dev/null +++ b/Modules/Ai/rag/failure_contract.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FailureContract: + status: str = "error" + error_code: str = "internal_error" + message: str = "" + source: str = "application" + warnings: list[str] = field(default_factory=list) + retriable: bool = False + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + payload = { + "status": self.status, + "error_code": self.error_code, + "message": self.message, + "source": self.source, + "warnings": list(self.warnings), + "retriable": self.retriable, + } + if self.details: + payload["details"] = self.details + return payload + + +class RAGServiceError(Exception): + def __init__( + self, + *, + error_code: str, + message: str, + source: str, + warnings: list[str] | None = None, + retriable: bool = False, + details: dict[str, Any] | None = None, + http_status: int = 500, + ) -> None: + super().__init__(message) + self.http_status = http_status + self.contract = FailureContract( + error_code=error_code, + message=message, + source=source, + warnings=warnings or [], + retriable=retriable, + details=details or {}, + ) + + def to_dict(self) -> dict[str, Any]: + return self.contract.to_dict() diff --git a/Modules/Ai/rag/ingest.py b/Modules/Ai/rag/ingest.py new file mode 100644 index 0000000..3f5f1e2 --- /dev/null +++ b/Modules/Ai/rag/ingest.py @@ -0,0 +1,187 @@ +""" +پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش + +منابع: +۱. لحن هر پایگاه دانش (tone) — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization +۲. پایگاه‌های دانش سه‌گانه — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization +۳. دیتای خاک + هواشناسی هر کاربر از DB — sensor_uuid=uuid, kb_name=__all__ +""" +import uuid +from pathlib import Path + +from .chunker import chunk_text, chunk_texts +from .config import load_rag_config, RAGConfig +from .embedding import embed_texts +from .observability import classify_exception, log_event, observe_operation, record_metric +from .user_data import load_user_sources, build_user_weather_text +from .vector_store import QdrantVectorStore + +TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"} + +SENSOR_UUID_GLOBAL = "__global__" + +KB_NAME_ALL = "__all__" + + +def _resolve_path(base: Path, p: str) -> Path: + """تبدیل مسیر نسبی به مطلق نسبت به base پروژه.""" + path = Path(p) + if not path.is_absolute(): + path = base / path + return path + + +def _load_file(path: Path) -> str | None: + """خواندن یک فایل متنی.""" + if not path.exists() or not path.is_file(): + return None + try: + return path.read_text(encoding="utf-8").strip() + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=40, + message="rag ingest file load failed", + source="rag.ingest", + provider=None, + operation="load_file", + result_status="error", + error_code=failure.error_code, + path=str(path), + ) + record_metric("rag.ingest.file_load_failure", error_code=failure.error_code) + return None + + +def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str, str]]: + """ + خواندن همه فایل‌های متنی از یک دایرکتوری. + Returns: [(source_id, content), ...] + """ + if not dir_path.exists() or not dir_path.is_dir(): + return [] + out: list[tuple[str, str]] = [] + for f in sorted(dir_path.rglob("*")): + if f.is_file() and f.suffix.lower() in TEXT_EXTENSIONS: + rel = f.relative_to(dir_path) + source_id = f"{prefix}:{rel}" + content = _load_file(f) + if content: + out.append((source_id, content)) + return out + + +def load_sources( + config: RAGConfig | None = None, + kb_name: str | None = None, +) -> list[tuple[str, str, str, str]]: + """ + بارگذاری منابع: لحن‌ها، پایگاه‌های دانش سه‌گانه، دیتای کاربران. + اگر kb_name مشخص شود، فقط آن پایگاه دانش لود می‌شود. + + Returns: + [(source_id, content, sensor_uuid, kb_name), ...] + """ + cfg = config or load_rag_config() + base = Path(__file__).resolve().parent.parent + sources: list[tuple[str, str, str, str]] = [] + + kbs_to_load = cfg.knowledge_bases.items() + if kb_name: + kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name] + + for kbn, kb_cfg in kbs_to_load: + tone_path = _resolve_path(base, kb_cfg.tone_file) + content = _load_file(tone_path) + if content: + sources.append((f"tone:{kbn}", content, SENSOR_UUID_GLOBAL, kbn)) + + kb_path = _resolve_path(base, kb_cfg.path) + for sid, c in _load_files_from_dir(kb_path, prefix=f"kb:{kbn}"): + sources.append((sid, c, SENSOR_UUID_GLOBAL, kbn)) + if kb_path.is_file(): + content = _load_file(kb_path) + if content: + sources.append((f"kb:{kbn}:{kb_path.name}", content, SENSOR_UUID_GLOBAL, kbn)) + + for sid, content in load_user_sources(): + if sid.startswith("user:"): + sensor_uuid = sid.replace("user:", "") + elif sid.startswith("weather:"): + sensor_uuid = sid.replace("weather:", "") + else: + sensor_uuid = sid + sources.append((sid, content, sensor_uuid, KB_NAME_ALL)) + + return sources + + +def ingest( + recreate: bool = False, + config: RAGConfig | None = None, + kb_name: str | None = None, +) -> dict: + """ + ورودی کامل: منابع را می‌خواند، چانک، embed و به vector store می‌فرستد. + kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest می‌شود. + + Args: + recreate: اگر True باشد، collection را از نو می‌سازد + config: تنظیمات RAG + kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری + + Returns: + آمار ورودی (تعداد چانک، منبع‌ها، خطاها) + """ + cfg = config or load_rag_config() + store = QdrantVectorStore(config=cfg) + with observe_operation(source="rag.ingest", provider=cfg.embedding.provider, operation="ingest"): + if recreate: + store.ensure_collection(recreate=True) + + sources = load_sources(config=cfg, kb_name=kb_name) + if not sources: + record_metric("rag.ingest.empty_sources", kb_name=kb_name) + return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} + + all_chunks: list[str] = [] + all_metas: list[dict] = [] + all_ids: list[str] = [] + + for source_id, content, sensor_uuid, src_kb in sources: + chunks = chunk_text(content, config=cfg) + for i, ch in enumerate(chunks): + uid = str(uuid.uuid4()) + all_ids.append(uid) + all_chunks.append(ch) + all_metas.append({ + "source": source_id, + "chunk_index": i, + "sensor_uuid": sensor_uuid, + "kb_name": src_kb, + }) + + if not all_chunks: + record_metric("rag.ingest.empty_chunks", kb_name=kb_name) + return {"chunks_added": 0, "sources": [s[0] for s in sources], "error": "هیچ چانکی ساخته نشد"} + + embeddings = embed_texts(all_chunks, config=cfg) + if len(embeddings) != len(all_chunks): + record_metric("rag.ingest.embedding_mismatch", kb_name=kb_name) + return { + "chunks_added": 0, + "sources": [s[0] for s in sources], + "error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}", + } + + store.add_documents( + ids=all_ids, + embeddings=embeddings, + documents=all_chunks, + metadatas=all_metas, + ) + record_metric("rag.ingest.success", kb_name=kb_name, chunks=len(all_chunks)) + return { + "chunks_added": len(all_chunks), + "sources": [s[0] for s in sources], + } diff --git a/Modules/Ai/rag/management/__init__.py b/Modules/Ai/rag/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/rag/management/commands/__init__.py b/Modules/Ai/rag/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Ai/rag/management/commands/rag_ingest.py b/Modules/Ai/rag/management/commands/rag_ingest.py new file mode 100644 index 0000000..5b0df87 --- /dev/null +++ b/Modules/Ai/rag/management/commands/rag_ingest.py @@ -0,0 +1,30 @@ +""" +ورودی RAG: لحن، پایگاه دانش و اطلاعات کاربر را embed و به Qdrant می‌فرستد. +اجرا: python manage.py rag_ingest [--recreate] +""" +from django.core.management.base import BaseCommand + +from rag.ingest import ingest + + +class Command(BaseCommand): + help = "Embed لحن، پایگاه دانش و اطلاعات کاربر و ذخیره در Qdrant" + + def add_arguments(self, parser): + parser.add_argument( + "--recreate", + action="store_true", + help="collection را از نو بساز (حذف و ایجاد مجدد)", + ) + + def handle(self, *args, **options): + recreate = options.get("recreate", False) + result = ingest(recreate=recreate) + if "error" in result: + self.stderr.write(self.style.ERROR(result["error"])) + return + self.stdout.write( + self.style.SUCCESS( + f"✓ {result['chunks_added']} چانک از منابع {result['sources']} ذخیره شد." + ) + ) diff --git a/Modules/Ai/rag/migrations/0001_initial.py b/Modules/Ai/rag/migrations/0001_initial.py new file mode 100644 index 0000000..74d6e30 --- /dev/null +++ b/Modules/Ai/rag/migrations/0001_initial.py @@ -0,0 +1,33 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ChatAuditLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(blank=True, help_text="شناسه مزرعه مرتبط با درخواست چت", null=True)), + ("service_id", models.CharField(default="chat", help_text="شناسه سرویس RAG استفاده شده برای این درخواست", max_length=64)), + ("model", models.CharField(blank=True, help_text="مدل LLM استفاده شده برای پاسخ", max_length=128)), + ("user_query", models.TextField(help_text="متن پرسش کاربر")), + ("system_prompt", models.TextField(blank=True, help_text="system prompt نهایی ارسال شده به مدل")), + ("messages", models.JSONField(blank=True, default=list, help_text="لیست کامل پیام‌های ارسال شده به مدل")), + ("response_text", models.TextField(blank=True, help_text="متن کامل پاسخ دریافتی از مدل")), + ("error_message", models.TextField(blank=True, help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم")), + ("status", models.CharField(choices=[("started", "شروع شده"), ("completed", "تکمیل شده"), ("failed", "ناموفق")], default="started", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "rag_chatauditlog", + "ordering": ["-created_at"], + "verbose_name": "لاگ چت RAG", + "verbose_name_plural": "لاگ\u200cهای چت RAG", + }, + ), + ] diff --git a/Modules/Ai/rag/migrations/__init__.py b/Modules/Ai/rag/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/rag/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/rag/models.py b/Modules/Ai/rag/models.py new file mode 100644 index 0000000..bfbd1ef --- /dev/null +++ b/Modules/Ai/rag/models.py @@ -0,0 +1,62 @@ +from django.db import models + + +class ChatAuditLog(models.Model): + STATUS_STARTED = "started" + STATUS_COMPLETED = "completed" + STATUS_FAILED = "failed" + STATUS_CHOICES = [ + (STATUS_STARTED, "شروع شده"), + (STATUS_COMPLETED, "تکمیل شده"), + (STATUS_FAILED, "ناموفق"), + ] + + farm_uuid = models.UUIDField( + null=True, + blank=True, + help_text="شناسه مزرعه مرتبط با درخواست چت", + ) + service_id = models.CharField( + max_length=64, + default="chat", + help_text="شناسه سرویس RAG استفاده شده برای این درخواست", + ) + model = models.CharField( + max_length=128, + blank=True, + help_text="مدل LLM استفاده شده برای پاسخ", + ) + user_query = models.TextField(help_text="متن پرسش کاربر") + system_prompt = models.TextField( + blank=True, + help_text="system prompt نهایی ارسال شده به مدل", + ) + messages = models.JSONField( + default=list, + blank=True, + help_text="لیست کامل پیام‌های ارسال شده به مدل", + ) + response_text = models.TextField( + blank=True, + help_text="متن کامل پاسخ دریافتی از مدل", + ) + error_message = models.TextField( + blank=True, + help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_STARTED, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "rag_chatauditlog" + ordering = ["-created_at"] + verbose_name = "لاگ چت RAG" + verbose_name_plural = "لاگ‌های چت RAG" + + def __str__(self): + return f"{self.service_id} - {self.farm_uuid or 'no-farm'} - {self.status}" diff --git a/Modules/Ai/rag/observability.py b/Modules/Ai/rag/observability.py new file mode 100644 index 0000000..b1a84e5 --- /dev/null +++ b/Modules/Ai/rag/observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import logging +import time +from collections import Counter +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + + +logger = logging.getLogger(__name__) +_request_id_ctx: ContextVar[str | None] = ContextVar("rag_request_id", default=None) +METRICS: Counter[str] = Counter() + + +def set_request_id(request_id: str | None) -> None: + _request_id_ctx.set(request_id) + + +def get_request_id() -> str | None: + return _request_id_ctx.get() + + +def record_metric(name: str, value: int = 1, **tags: Any) -> None: + suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None) + metric_key = f"{name}|{suffix}" if suffix else name + METRICS[metric_key] += value + + +@dataclass +class ClassifiedFailure: + error_code: str + failure_type: str + retriable: bool + + +def classify_exception(exc: Exception) -> ClassifiedFailure: + exc_name = exc.__class__.__name__.lower() + message = str(exc).lower() + if "timeout" in exc_name or "timeout" in message: + return ClassifiedFailure("timeout", "timeout", True) + if "json" in exc_name or "json" in message: + return ClassifiedFailure("parse_error", "parse_error", False) + if "validation" in exc_name or "invalid" in message: + return ClassifiedFailure("validation_failure", "validation_failure", False) + if "connection" in exc_name or "unavailable" in message: + return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True) + return ClassifiedFailure("provider_error", "provider_error", True) + + +def log_event( + *, + level: int, + message: str, + source: str, + provider: str | None, + operation: str, + result_status: str, + duration_ms: float | None = None, + error_code: str | None = None, + **extra: Any, +) -> None: + payload = { + "source": source, + "provider": provider, + "operation": operation, + "result_status": result_status, + "duration_ms": round(duration_ms, 2) if duration_ms is not None else None, + "error_code": error_code, + "request_id": get_request_id(), + } + payload.update({key: value for key, value in extra.items() if value is not None}) + logger.log(level, message, extra={"event": payload}) + + +class observe_operation: + def __init__(self, *, source: str, provider: str | None, operation: str): + self.source = source + self.provider = provider + self.operation = operation + self.started_at = 0.0 + + def __enter__(self): + self.started_at = time.monotonic() + log_event( + level=logging.INFO, + message="rag operation started", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="started", + ) + return self + + def __exit__(self, exc_type, exc, _tb): + duration_ms = (time.monotonic() - self.started_at) * 1000 + if exc is None: + log_event( + level=logging.INFO, + message="rag operation completed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="success", + duration_ms=duration_ms, + ) + record_metric("rag.operation.success", source=self.source, provider=self.provider, operation=self.operation) + return False + + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="rag operation failed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="error", + duration_ms=duration_ms, + error_code=failure.error_code, + failure_type=failure.failure_type, + ) + record_metric( + "rag.operation.failure", + source=self.source, + provider=self.provider, + operation=self.operation, + error_code=failure.error_code, + ) + return False diff --git a/Modules/Ai/rag/retrieve.py b/Modules/Ai/rag/retrieve.py new file mode 100644 index 0000000..c72f919 --- /dev/null +++ b/Modules/Ai/rag/retrieve.py @@ -0,0 +1,134 @@ +""" +بازیابی RAG: embed کوئری و جستجو در vector store +""" +from .config import load_rag_config, RAGConfig, get_service_config +from .embedding import embed_single, embed_texts +from .observability import observe_operation, record_metric +from .vector_store import QdrantVectorStore + + +def _resolve_search_options( + sensor_uuid: str | None = None, + config: RAGConfig | None = None, + kb_name: str | None = None, + service_id: str | None = None, + use_user_embeddings: bool | None = None, +) -> tuple[RAGConfig, list[str], list[str]]: + cfg = config or load_rag_config() + service = get_service_config(service_id, cfg) if service_id else None + resolved_kb_name = kb_name or (service.knowledge_base if service else None) + include_user_embeddings = ( + use_user_embeddings + if use_user_embeddings is not None + else (service.use_user_embeddings if service else True) + ) + + sensor_filters = ["__global__"] + if include_user_embeddings and sensor_uuid: + sensor_filters.insert(0, sensor_uuid) + + kb_filters = [resolved_kb_name] if resolved_kb_name else [] + if include_user_embeddings: + kb_filters.append("__all__") + + return cfg, sensor_filters, kb_filters + + +def search_with_query( + query: str, + sensor_uuid: str | None = None, + limit: int = 5, + score_threshold: float | None = None, + config: RAGConfig | None = None, + kb_name: str | None = None, + service_id: str | None = None, + use_user_embeddings: bool | None = None, +) -> list[dict]: + """ + کوئری را embed می‌کند و در vector store جستجو می‌کند. + فقط chunks مربوط به sensor_uuid یا __global__ برمی‌گردد (ایزوله‌سازی کاربر). + kb_name: اختیاری — فیلتر بر اساس پایگاه دانش. + + Args: + sensor_uuid: شناسه سنسور کاربر — اجباری برای امنیت + kb_name: نام پایگاه دانش (chat/irrigation/fertilization) + + Returns: + لیست نتایج با id, score, text, metadata + """ + cfg, sensor_filters, kb_filters = _resolve_search_options( + sensor_uuid=sensor_uuid, + config=config, + kb_name=kb_name, + service_id=service_id, + use_user_embeddings=use_user_embeddings, + ) + + with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_query"): + query_vector = embed_single(query, config=cfg) + store = QdrantVectorStore(config=cfg) + results = store.search( + query_vector=query_vector, + limit=limit, + score_threshold=score_threshold, + sensor_uuids=sensor_filters, + kb_names=kb_filters, + ) + if not results: + record_metric("rag.retrieve.empty_result", operation="search_with_query", service_id=service_id) + return results + + +def search_with_texts( + texts: list[str], + sensor_uuid: str | None = None, + limit: int = 8, + per_text_limit: int = 3, + score_threshold: float | None = None, + config: RAGConfig | None = None, + kb_name: str | None = None, + service_id: str | None = None, + use_user_embeddings: bool | None = None, +) -> list[dict]: + """ + چند متن را embed می‌کند و نتیجه جستجوها را به صورت dedupe شده برمی‌گرداند. + برای حالتی مناسب است که هم پیام کاربر و هم داده‌های مزرعه را علیه KB جستجو کنیم. + """ + normalized_texts = [text.strip() for text in texts if text and text.strip()] + if not normalized_texts: + return [] + + cfg, sensor_filters, kb_filters = _resolve_search_options( + sensor_uuid=sensor_uuid, + config=config, + kb_name=kb_name, + service_id=service_id, + use_user_embeddings=use_user_embeddings, + ) + + store = QdrantVectorStore(config=cfg) + with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_texts"): + vectors = embed_texts(normalized_texts, config=cfg) + merged_results: dict[str, dict] = {} + + for vector in vectors: + results = store.search( + query_vector=vector, + limit=per_text_limit, + score_threshold=score_threshold, + sensor_uuids=sensor_filters, + kb_names=kb_filters, + ) + for item in results: + current = merged_results.get(item["id"]) + if current is None or item["score"] > current["score"]: + merged_results[item["id"]] = item + + final_results = sorted( + merged_results.values(), + key=lambda item: item["score"], + reverse=True, + )[:limit] + if not final_results: + record_metric("rag.retrieve.empty_result", operation="search_with_texts", service_id=service_id) + return final_results diff --git a/Modules/Ai/rag/services/__init__.py b/Modules/Ai/rag/services/__init__.py new file mode 100644 index 0000000..8c28435 --- /dev/null +++ b/Modules/Ai/rag/services/__init__.py @@ -0,0 +1,24 @@ +""" +سرویس‌های RAG — آبیاری و کودهی +بدون API — قابل استفاده از سایر سرویس‌ها +""" +from .irrigation import get_irrigation_recommendation +from .irrigation_plan_parser import IrrigationPlanParserService +from .fertilization import get_fertilization_recommendation +from .fertilization_plan_parser import FertilizationPlanParserService +from .pest_disease import get_pest_disease_detection, get_pest_disease_risk +from .soil_anomaly import get_soil_anomaly_insight +from .water_need_prediction import get_water_need_prediction_insight +from .yield_harvest import YieldHarvestRAGService + +__all__ = [ + "get_irrigation_recommendation", + "IrrigationPlanParserService", + "get_fertilization_recommendation", + "FertilizationPlanParserService", + "get_pest_disease_detection", + "get_pest_disease_risk", + "get_soil_anomaly_insight", + "get_water_need_prediction_insight", + "YieldHarvestRAGService", +] diff --git a/Modules/Ai/rag/services/fertilization.py b/Modules/Ai/rag/services/fertilization.py new file mode 100644 index 0000000..cc61870 --- /dev/null +++ b/Modules/Ai/rag/services/fertilization.py @@ -0,0 +1,738 @@ +""" +سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویس‌ها. +از RAG با پایگاه دانش fertilization و خروجی optimizer برای ساخت پاسخ ساختاریافته استفاده می‌کند. +""" + +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +from django.apps import apps + +from farm_data.models import SensorData +from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.user_data import build_plant_text + +logger = logging.getLogger(__name__) + +KB_NAME = "fertilization" +SERVICE_ID = "fertilization" +HECTARE_TO_SQUARE_METER = 10000.0 + +DEFAULT_FERTILIZATION_PROMPT = ( + "از RAG و خروجی بهینه ساز شبیه سازی برای ساخت پاسخ ساختاریافته کودهی استفاده کن. " + "اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان مرجع قطعی اعداد، فرمول، روش مصرف و زمان بندی است. " + "پاسخ فقط JSON معتبر بر اساس قرارداد status/data برگردان." +) + +DEFAULT_MACRO_DESCRIPTIONS = { + "n": "نیتروژن برای حفظ رشد رویشی، رنگ سبز برگ و بازسازی سریع بوته مهم است.", + "p": "فسفر به توسعه ریشه، انتقال انرژی و پشتیبانی از گلدهی و استقرار کمک می کند.", + "k": "پتاسیم به تنظیم آب، کیفیت محصول و مقاومت گیاه در برابر تنش محیطی کمک می کند.", +} + +DEFAULT_MICRO_NAMES = { + "fe": "آهن", + "zn": "روی", + "mn": "منگنز", + "b": "بر", + "cu": "مس", + "mg": "منیزیم", + "ca": "کلسیم", + "mo": "مولیبدن", +} + +DEFAULT_MICRO_DESCRIPTIONS = { + "fe": "آهن در ساخت کلروفیل و کاهش زردی بین رگبرگی نقش دارد.", + "zn": "روی در رشد متعادل، تشکیل هورمون ها و فعالیت آنزیمی موثر است.", + "mn": "منگنز در فتوسنتز و فعالیت آنزیم های متابولیکی نقش پشتیبان دارد.", + "b": "بر در گرده افشانی، تشکیل گل و انتقال قندها اهمیت دارد.", + "cu": "مس به فعالیت آنزیمی و استحکام نسبی بافت های گیاه کمک می کند.", + "mg": "منیزیم بخش مرکزی کلروفیل است و در فتوسنتز اهمیت دارد.", + "ca": "کلسیم در استحکام دیواره سلولی و کیفیت رشد بافت های جوان موثر است.", + "mo": "مولیبدن در متابولیسم نیتروژن و کارایی جذب آن نقش دارد.", +} + +DEFAULT_STAGE_LABELS = { + "initial": "استقرار", + "vegetative": "رشد رویشی", + "flowering": "گلدهی", + "fruiting": "میوه دهی", +} + + +def _get_optimizer(): + return apps.get_app_config("crop_simulation").get_recommendation_optimizer() + + +def _safe_float(value: Any, default: float | None = None) -> float | None: + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default + + +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 _clean_json_response(raw: str) -> dict[str, Any]: + cleaned = raw.strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`").removeprefix("json").strip() + try: + parsed = json.loads(cleaned) + return parsed if isinstance(parsed, dict) else {} + except (json.JSONDecodeError, ValueError): + return {} + + +def _normalize_label(value: float) -> str: + if float(value).is_integer(): + return str(int(value)) + return f"{value:.2f}".rstrip("0").rstrip(".") + + +def _parse_npk_ratio(formula: str | None) -> dict[str, float | str]: + if not formula: + return {"n": 0.0, "p": 0.0, "k": 0.0, "label": "0-0-0"} + + parts = re.findall(r"\d+(?:\.\d+)?", formula) + if len(parts) < 3: + return {"n": 0.0, "p": 0.0, "k": 0.0, "label": formula} + + n, p, k = (_safe_float(part, 0.0) or 0.0 for part in parts[:3]) + return { + "n": round(n, 3), + "p": round(p, 3), + "k": round(k, 3), + "label": f"{_normalize_label(n)}-{_normalize_label(p)}-{_normalize_label(k)}", + } + + +def _method_id(label: str) -> str: + text = (label or "").strip() + if "محلول" in text and ("آبیاری" in text or "کودآبیاری" in text): + return "foliar_fertigation" + if "محلول" in text: + return "foliar_spray" + if "آبیاری" in text or "کودآبیاری" in text: + return "fertigation" + if "سرک" in text or "خاک" in text or "نواری" in text: + return "soil_application" + return "custom_application" + + +def _slug_value(value: str) -> str: + token = re.sub(r"[^a-zA-Z0-9]+", "-", (value or "").strip().lower()).strip("-") + return token or "fertilizer" + + +def _fertilizer_display_name(formula: str | None) -> str: + ratio = _parse_npk_ratio(formula) + label = ratio["label"] if ratio["label"] else (formula or "کود پیشنهادی") + if label and label != "0-0-0": + return f"کود کامل {label}" + return formula or "کود پیشنهادی" + + +def _fertilizer_type_label(formula: str | None) -> str: + ratio = _parse_npk_ratio(formula) + if ratio["label"] and ratio["label"] != "0-0-0": + return "NPK" + return formula or "Fertilizer" + + +def _first_text(*values: Any) -> str: + for value in values: + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _default_application_steps(application_method: str) -> list[dict[str, Any]]: + if "محلول" in application_method: + return [ + { + "step_number": 1, + "title": "آماده سازی", + "description": "دوز توصیه شده را در مقدار کمی آب تمیز حل کنید تا محلول یکنواخت به دست آید.", + }, + { + "step_number": 2, + "title": "اختلاط", + "description": "محلول را به مخزن اصلی اضافه کنید و همزمان هم بزنید تا ته نشینی رخ ندهد.", + }, + { + "step_number": 3, + "title": "مصرف", + "description": "در ساعات خنک روز به صورت یکنواخت محلول پاشی کنید و پس از اجرا بوته را پایش کنید.", + }, + ] + + return [ + { + "step_number": 1, + "title": "آماده سازی", + "description": "مقدار توصیه شده را بر اساس مساحت مزرعه اندازه گیری و پیش از اجرا یکنواخت تقسیم کنید.", + }, + { + "step_number": 2, + "title": "تزریق یا پخش", + "description": "کود را از طریق کودآبیاری یا مصرف خاکی سبک مطابق روش پیشنهادی وارد مزرعه کنید.", + }, + { + "step_number": 3, + "title": "پایش", + "description": "پس از اجرا رطوبت خاک، وضعیت برگ و پاسخ بوته را تا نوبت بعدی بررسی کنید.", + }, + ] + + +def _warning_from_weather(forecasts: list[Any], application_method: str) -> str: + if not forecasts: + return "هنگام مصرف از دستکش و ماسک استفاده کنید و قبل از اختلاط آزمون سازگاری در مقیاس کوچک انجام دهید." + + rainy = next( + ( + item + for item in forecasts + if (_safe_float(getattr(item, "precipitation", None), 0.0) or 0.0) >= 3.0 + ), + None, + ) + hot = next( + ( + item + for item in forecasts + if (_safe_float(getattr(item, "temperature_max", None), 0.0) or 0.0) >= 32.0 + ), + None, + ) + + if rainy is not None and "محلول" in application_method: + return ( + f"به دلیل احتمال بارش موثر در {rainy.forecast_date} محلول پاشی را به پنجره خشک منتقل کنید و " + "در زمان اجرا از ماسک و دستکش استفاده شود." + ) + if hot is not None: + return ( + "به دلیل گرمای پیش رو، مصرف را فقط در صبح زود یا نزدیک غروب انجام دهید و از اختلاط غلیظ خودداری کنید." + ) + return "هنگام مصرف از دستکش و ماسک استفاده کنید و پیش از اختلاط با سایر نهاده ها آزمون سازگاری انجام دهید." + + +def _fallback_optimizer_result(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"]) + base_amount = round(max(40.0, (target["n"] * 1.25)), 2) + return { + "engine": "defaults", + "recommended_strategy": { + "code": stage_key, + "label": DEFAULT_STAGE_LABELS.get(stage_key, stage_key), + "score": 0.0, + "expected_yield_index": 0.0, + "fertilizer_type": target["formula"], + "amount_kg_per_ha": base_amount, + "application_method": target["application_method"], + "timing": target["timing"], + "validity_period": f"معتبر برای {defaults['validity_days']} روز آینده", + "reasoning": [ + "پیشنهاد از تنظیمات پایه مرحله رشد ساخته شد زیرا خروجی کامل optimizer در دسترس نبود.", + f"فرمول هدف مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} برابر با {target['formula']} در نظر گرفته شد.", + ], + }, + "alternatives": [], + "context_text": "fallback fertilization context", + } + + +def _build_legacy_sections( + structured_data: dict[str, Any], + recommended_strategy: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + primary = structured_data.get("primary_recommendation", {}) + guide = structured_data.get("application_guide", {}) + recommended_strategy = recommended_strategy or {} + return [ + { + "type": "recommendation", + "title": primary.get("display_title") or "برنامه کودهی", + "icon": "leaf", + "content": primary.get("summary", ""), + "fertilizerType": primary.get("npk_ratio", {}).get("label") or primary.get("fertilizer_type", ""), + "amount": primary.get("dosage", {}).get("label", ""), + "applicationMethod": primary.get("application_method", {}).get("label", ""), + "timing": recommended_strategy.get("timing", ""), + "validityPeriod": recommended_strategy.get("validity_period", ""), + "expandableExplanation": primary.get("reasoning", ""), + }, + { + "type": "list", + "title": "مراحل مصرف", + "icon": "list", + "items": [step.get("title", "") for step in guide.get("steps", []) if step.get("title")], + }, + { + "type": "warning", + "title": "هشدار کودهی", + "icon": "alert-triangle", + "content": guide.get("safety_warning", ""), + }, + ] + + +def _coerce_steps(value: Any, application_method: str) -> list[dict[str, Any]]: + if not isinstance(value, list): + return _default_application_steps(application_method) + + steps = [] + for index, item in enumerate(value, start=1): + if isinstance(item, dict): + title = _first_text(item.get("title"), f"مرحله {index}") + description = _first_text(item.get("description"), item.get("content")) + if not description: + continue + steps.append( + { + "step_number": int(item.get("step_number") or index), + "title": title, + "description": description, + } + ) + elif isinstance(item, str) and item.strip(): + steps.append( + { + "step_number": index, + "title": f"مرحله {index}", + "description": item.strip(), + } + ) + return steps or _default_application_steps(application_method) + + +def _normalize_micro_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + + items = [] + for item in value: + if not isinstance(item, dict): + continue + key = _first_text(item.get("key")).lower() + if not key: + continue + nutrient_value = _safe_float(item.get("value")) + if nutrient_value is None: + continue + items.append( + { + "key": key, + "name": _first_text(item.get("name"), DEFAULT_MICRO_NAMES.get(key, key.upper())), + "value": round(nutrient_value, 3), + "unit": "percent", + "description": _first_text(item.get("description"), DEFAULT_MICRO_DESCRIPTIONS.get(key, "")), + } + ) + return items + + +def _build_nutrient_analysis(llm_analysis: dict[str, Any] | None, npk_ratio: dict[str, Any]) -> dict[str, Any]: + llm_analysis = llm_analysis if isinstance(llm_analysis, dict) else {} + macro_by_key: dict[str, dict[str, Any]] = {} + for item in llm_analysis.get("macro", []): + if not isinstance(item, dict): + continue + key = _first_text(item.get("key")).lower() + if key: + macro_by_key[key] = item + + macro = [] + for key, name in (("n", "نیتروژن (N)"), ("p", "فسفر (P)"), ("k", "پتاسیم (K)")): + source = macro_by_key.get(key, {}) + macro.append( + { + "key": key, + "name": name, + "value": round(_safe_float(npk_ratio.get(key), 0.0) or 0.0, 3), + "unit": "percent", + "description": _first_text(source.get("description"), DEFAULT_MACRO_DESCRIPTIONS[key]), + } + ) + + return {"macro": macro, "micro": _normalize_micro_items(llm_analysis.get("micro"))} + + +def _build_application_guide( + llm_guide: dict[str, Any] | None, + *, + application_method: str, + warning_text: str, +) -> dict[str, Any]: + llm_guide = llm_guide if isinstance(llm_guide, dict) else {} + return { + "safety_warning": _first_text(llm_guide.get("safety_warning"), warning_text), + "steps": _coerce_steps(llm_guide.get("steps"), application_method), + } + + +def _build_alternative_recommendations( + llm_alternatives: Any, + optimizer_alternatives: list[dict[str, Any]], + recommended_strategy: dict[str, Any], +) -> list[dict[str, Any]]: + llm_items = llm_alternatives if isinstance(llm_alternatives, list) else [] + alternatives = [] + + for index, optimizer_item in enumerate(optimizer_alternatives[:3]): + llm_item = llm_items[index] if index < len(llm_items) and isinstance(llm_items[index], dict) else {} + formula = _first_text( + llm_item.get("fertilizer_code"), + optimizer_item.get("fertilizer_type"), + recommended_strategy.get("fertilizer_type"), + ) + display_name = _first_text(llm_item.get("fertilizer_name"), _fertilizer_display_name(formula), optimizer_item.get("label")) + description = _first_text( + llm_item.get("description"), + *(optimizer_item.get("reasoning") or []), + f"این گزینه با امتیاز {optimizer_item.get('score', 0)} برای شرایط مشابه قابل استفاده است.", + ) + alternatives.append( + { + "fertilizer_code": _slug_value(formula or optimizer_item.get("code", f"alt-{index + 1}")), + "fertilizer_name": display_name, + "fertilizer_type": _first_text(llm_item.get("fertilizer_type"), _fertilizer_type_label(formula)), + "usage_method": _first_text( + llm_item.get("usage_method"), + optimizer_item.get("application_method"), + recommended_strategy.get("application_method"), + ), + "description": description, + } + ) + + for llm_item in llm_items[len(alternatives):3]: + if not isinstance(llm_item, dict): + continue + fertilizer_name = _first_text(llm_item.get("fertilizer_name")) + fertilizer_code = _first_text(llm_item.get("fertilizer_code"), fertilizer_name) + if not fertilizer_name or not fertilizer_code: + continue + alternatives.append( + { + "fertilizer_code": _slug_value(fertilizer_code), + "fertilizer_name": fertilizer_name, + "fertilizer_type": _first_text(llm_item.get("fertilizer_type"), "Fertilizer"), + "usage_method": _first_text(llm_item.get("usage_method"), recommended_strategy.get("application_method", "")), + "description": _first_text(llm_item.get("description"), "گزینه جایگزین در صورت محدودیت تامین یا تغییر شرایط مزرعه."), + } + ) + + return alternatives + + +def _normalize_llm_payload(parsed_result: dict[str, Any]) -> dict[str, Any]: + if not isinstance(parsed_result, dict): + return {"status": "success", "data": {}} + + if isinstance(parsed_result.get("data"), dict): + status = parsed_result.get("status") or "success" + return {"status": status, "data": parsed_result["data"]} + + if any(key in parsed_result for key in ("primary_recommendation", "nutrient_analysis", "application_guide")): + status = parsed_result.get("status") or "success" + return {"status": status, "data": parsed_result} + + sections = parsed_result.get("sections") + if isinstance(sections, list): + recommendation = next((item for item in sections if isinstance(item, dict) and item.get("type") == "recommendation"), {}) + list_section = next((item for item in sections if isinstance(item, dict) and item.get("type") == "list"), {}) + warning = next((item for item in sections if isinstance(item, dict) and item.get("type") == "warning"), {}) + return { + "status": "success", + "data": { + "primary_recommendation": { + "display_title": _first_text(recommendation.get("title"), recommendation.get("fertilizerType")), + "reasoning": _first_text(recommendation.get("expandableExplanation"), recommendation.get("content")), + "summary": _first_text(recommendation.get("content"), recommendation.get("title")), + }, + "application_guide": { + "safety_warning": _first_text(warning.get("content")), + "steps": list_section.get("items", []), + }, + "alternative_recommendations": [], + }, + } + + return {"status": "success", "data": {}} + + +def _build_final_response( + *, + llm_payload: dict[str, Any], + optimized_result: dict[str, Any] | None, + plant_name: str | None, + crop_id: str | None, + growth_stage: str | None, + forecasts: list[Any], +) -> dict[str, Any]: + normalized_llm = _normalize_llm_payload(llm_payload) + advisory = normalized_llm.get("data", {}) if isinstance(normalized_llm.get("data"), dict) else {} + optimizer_payload = optimized_result or _fallback_optimizer_result(growth_stage) + recommended = optimizer_payload.get("recommended_strategy", {}) + + defaults = apps.get_app_config("fertilization").get_optimizer_defaults() + stage_key = _stage_key(growth_stage) + stage_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"]) + + formula = _first_text(recommended.get("fertilizer_type"), stage_target.get("formula")) + npk_ratio = _parse_npk_ratio(formula) + application_method_label = _first_text(recommended.get("application_method"), stage_target.get("application_method")) + amount_kg_per_ha = round(_safe_float(recommended.get("amount_kg_per_ha"), 0.0) or 0.0, 3) + amount_per_square_meter = round(amount_kg_per_ha / HECTARE_TO_SQUARE_METER, 6) + interval_days = int( + stage_target.get( + "application_interval_days", + defaults.get("default_application_interval_days", 14), + ) + ) + + primary_advisory = advisory.get("primary_recommendation") if isinstance(advisory.get("primary_recommendation"), dict) else {} + reasoning = _first_text(primary_advisory.get("reasoning"), " ".join(recommended.get("reasoning", []))) + if not reasoning: + reasoning = "این توصیه با اتکا به مرحله رشد، وضعیت خاک و خروجی بهینه ساز شبیه سازی تنظیم شده است." + + summary = _first_text(primary_advisory.get("summary")) + if not summary: + summary = f"{_fertilizer_display_name(formula)} برای مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} مناسب ارزیابی شده است." + + warning_text = _warning_from_weather(forecasts, application_method_label) + nutrient_analysis = _build_nutrient_analysis(advisory.get("nutrient_analysis"), npk_ratio) + application_guide = _build_application_guide( + advisory.get("application_guide"), + application_method=application_method_label, + warning_text=warning_text, + ) + alternatives = _build_alternative_recommendations( + advisory.get("alternative_recommendations"), + optimizer_payload.get("alternatives", []), + recommended, + ) + + structured_data = { + "primary_recommendation": { + "fertilizer_code": _slug_value(formula), + "fertilizer_name": _first_text(primary_advisory.get("fertilizer_name"), _fertilizer_display_name(formula)), + "display_title": _first_text(primary_advisory.get("display_title"), _fertilizer_display_name(formula)), + "fertilizer_type": _first_text(primary_advisory.get("fertilizer_type"), _fertilizer_type_label(formula)), + "npk_ratio": npk_ratio, + "application_method": { + "id": _method_id(application_method_label), + "label": application_method_label, + }, + "application_interval": { + "value": interval_days, + "unit": "day", + "label": f"هر {interval_days} روز", + }, + "dosage": { + "base_amount_per_hectare": amount_kg_per_ha, + "base_amount_per_square_meter": amount_per_square_meter, + "unit": "kg", + "label": f"{_normalize_label(amount_kg_per_ha)} کیلوگرم در هکتار", + "calculation_basis": optimizer_payload.get("engine", "product"), + }, + "reasoning": reasoning, + "summary": summary, + }, + "nutrient_analysis": nutrient_analysis, + "application_guide": application_guide, + "alternative_recommendations": alternatives, + } + + structured_data["sections"] = _build_legacy_sections(structured_data, recommended) + return {"status": normalized_llm.get("status") or "success", "data": structured_data} + + +def _validate_fertilization_response(parsed_result: dict[str, Any]) -> dict[str, Any]: + if not isinstance(parsed_result, dict): + raise ValueError("Fertilization recommendation response is not a JSON object.") + data = parsed_result.get("data") + if not isinstance(data, dict): + raise ValueError("Fertilization recommendation response is missing data.") + if not isinstance(data.get("primary_recommendation"), dict): + raise ValueError("Fertilization recommendation response is missing primary_recommendation.") + return parsed_result + + +def get_fertilization_recommendation( + farm_uuid: str | None = None, + plant_name: str | None = None, + growth_stage: str | None = None, + crop_id: str | None = None, + query: str | None = None, + config: RAGConfig | None = None, + limit: int = 8, + sensor_uuid: str | None = None, +) -> dict[str, Any]: + """ + توصیه کودهی برای یک مزرعه. + از RAG با پایگاه دانش fertilization استفاده می کند و خروجی نهایی را با optimizer ترکیب می کند. + """ + cfg = config or load_rag_config() + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service.llm.model + + resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip() + if not resolved_farm_uuid: + raise ValueError("farm_uuid is required.") + + user_query = query or "توصیه کودهی بهینه برای مزرعه من چیست؟" + + sensor = ( + SensorData.objects.select_related("center_location") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=resolved_farm_uuid) + .first() + ) + + plant_config = apps.get_app_config("plant") + resolved_plant_name = plant_config.resolve_plant_name(plant_name) + if not resolved_plant_name and crop_id: + resolved_plant_name = plant_config.resolve_plant_name(crop_id) + resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage) + + plant = None + if sensor is not None: + selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name) + plant = clone_snapshot_as_runtime_plant( + selected_snapshot, + growth_stage=resolved_growth_stage, + ) + if selected_snapshot is not None: + resolved_plant_name = selected_snapshot.name + + forecasts = [] + optimized_result = None + if sensor is not None and getattr(sensor, "center_location", None) is not None: + from weather.models import WeatherForecast + + forecasts = list( + WeatherForecast.objects.filter( + location=sensor.center_location, + forecast_date__isnull=False, + ).order_by("forecast_date")[:7] + ) + if sensor is not None and plant is not None: + optimized_result = _get_optimizer().optimize_fertilization( + sensor=sensor, + plant=plant, + forecasts=forecasts, + growth_stage=resolved_growth_stage, + ) + + context = build_rag_context( + user_query, + resolved_farm_uuid, + config=cfg, + limit=limit, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + + extra_parts: list[str] = [] + if resolved_plant_name and resolved_growth_stage: + plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage) + if plant_text: + extra_parts.append("[اطلاعات گیاه]\n" + plant_text) + if optimized_result is not None: + extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"]) + if extra_parts: + context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") + + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(DEFAULT_FERTILIZATION_PROMPT) + if context: + system_parts.append("\n\n" + context) + system_content = "\n".join(system_parts) + + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_query}, + ] + audit_log = _create_audit_log( + farm_uuid=resolved_farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_content, + messages=messages, + ) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + ) + raw = response.choices[0].message.content.strip() + except Exception as exc: + logger.error("Fertilization recommendation error for %s: %s", resolved_farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RuntimeError( + f"Fertilization recommendation failed for farm {resolved_farm_uuid}." + ) from exc + + llm_payload = _clean_json_response(raw) + result = _build_final_response( + llm_payload=llm_payload, + optimized_result=optimized_result, + plant_name=resolved_plant_name, + crop_id=crop_id, + growth_stage=resolved_growth_stage, + forecasts=forecasts, + ) + result = _validate_fertilization_response(result) + result["raw_response"] = raw + result["simulation_optimizer"] = optimized_result + result["sections"] = result["data"].get("sections", []) + _complete_audit_log( + audit_log, + json.dumps(result, ensure_ascii=False, default=str), + ) + return result diff --git a/Modules/Ai/rag/services/fertilization_plan_parser.py b/Modules/Ai/rag/services/fertilization_plan_parser.py new file mode 100644 index 0000000..0a6c332 --- /dev/null +++ b/Modules/Ai/rag/services/fertilization_plan_parser.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Literal + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +logger = logging.getLogger(__name__) + +SERVICE_ID = "fertilization_plan_parser" +KB_NAME = "fertilization_plan_parser" +CORE_FIELDS = [ + "crop_name", + "growth_stage", + "fertilizer_name", + "formula", + "amount", + "application_method", + "timing", + "interval_days", +] + +FERTILIZATION_PLAN_PROMPT = ( + "شما یک تحلیل گر برنامه کودهی هستی. " + "کاربر ممکن است برنامه کودهی را کامل یا ناقص توضیح دهد. " + "فقط JSON معتبر برگردان و هرگز متن خارج از JSON، markdown یا کلید اضافه تولید نکن. " + "اگر اطلاعات کافی بود status را completed بگذار و final_plan را تکمیل کن. " + "اگر اطلاعات ناقص بود status را needs_clarification بگذار، missing_fields را پر کن و در questions سوال های کوتاه و دقیق برگردان. " + "اگر چند کود در متن بود، همه را در applications لیست کن. " + "اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. " + "در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. " + "از حدس زدن مقدار، زمان یا روش مصرف خودداری کن. " + "Schema: " + "{" + '"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": {same shape as collected_data} | null' + "}." +) + + +class ClarificationQuestionSchema(BaseModel): + id: str + field: str + question: str + rationale: str = "" + + +class FertilizerApplicationSchema(BaseModel): + fertilizer_name: str | None = None + formula: str | None = None + amount: str | None = None + application_method: str | None = None + timing: str | None = None + interval_days: int | None = None + purpose: str | None = None + + +class FertilizationPlanSchema(BaseModel): + crop_name: str | None = None + growth_stage: str | None = None + objective: str | None = None + applications: list[FertilizerApplicationSchema] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + + +class FertilizationPlanParseResultSchema(BaseModel): + status: Literal["completed", "needs_clarification"] + summary: str + missing_fields: list[str] = Field(default_factory=list) + questions: list[ClarificationQuestionSchema] = Field(default_factory=list) + collected_data: FertilizationPlanSchema = Field(default_factory=FertilizationPlanSchema) + final_plan: FertilizationPlanSchema | None = None + + +class FertilizationPlanParserService: + def parse_plan( + self, + *, + message: str = "", + answers: dict[str, Any] | None = None, + partial_plan: dict[str, Any] | None = None, + farm_uuid: str | None = None, + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + + normalized_message = (message or "").strip() + normalized_answers = answers if isinstance(answers, dict) else {} + normalized_partial = partial_plan if isinstance(partial_plan, dict) else {} + structured_context = { + "message": normalized_message, + "answers": normalized_answers, + "partial_plan": normalized_partial, + "required_core_fields": CORE_FIELDS, + "service": "fertilization_plan_parser", + } + + rag_query = self._build_retrieval_query( + message=normalized_message, + answers=normalized_answers, + ) + rag_context = build_rag_context( + query=rag_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + rag_context=rag_context, + ) + + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=rag_query, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Fertilization plan parser audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = FertilizationPlanParseResultSchema.model_validate(parsed) + normalized = self._normalize_result(validated) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return normalized + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Fertilization plan parser parsing failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + except Exception as exc: + logger.error("Fertilization plan parser failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + rag_context: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(FERTILIZATION_PLAN_PROMPT) + system_parts.append( + "[structured_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "برنامه کودهی را استخراج یا برای تکمیل آن سوال بپرس."}, + ] + return system_prompt, messages + + def _build_retrieval_query( + self, + *, + message: str, + answers: dict[str, Any], + ) -> str: + answer_lines = [f"{key}: {value}" for key, value in answers.items()] + parts = [part for part in [message, "\n".join(answer_lines)] if part] + return "\n".join(parts) or "استخراج برنامه کودهی از متن کاربر" + + def _normalize_result(self, validated: FertilizationPlanParseResultSchema) -> dict[str, Any]: + collected = validated.collected_data.model_dump() + final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None + missing_fields = list(dict.fromkeys(validated.missing_fields)) + computed_missing = self._find_missing_fields(final_plan or collected) + for field in computed_missing: + if field not in missing_fields: + missing_fields.append(field) + + can_complete = validated.status == "completed" and not missing_fields + + if can_complete: + final_plan = final_plan or collected + questions: list[dict[str, Any]] = [] + status_fa = "تکمیل شد" + else: + questions = [item.model_dump() for item in validated.questions] + if not questions and missing_fields: + questions = self._build_generic_questions(missing_fields) + final_plan = None + validated.status = "needs_clarification" + status_fa = "نیازمند پرسش تکمیلی" + + return { + "status": "completed" if can_complete else "needs_clarification", + "status_fa": status_fa, + "summary": validated.summary, + "missing_fields": missing_fields, + "questions": questions, + "collected_data": collected, + "final_plan": final_plan, + } + + def _fallback_result( + self, + *, + message: str, + answers: dict[str, Any], + partial_plan: dict[str, Any], + ) -> dict[str, Any]: + applications = partial_plan.get("applications") + if not isinstance(applications, list): + applications = [] + notes = list(partial_plan.get("notes") or []) + if message: + notes.append(f"متن اولیه کاربر: {message}") + if answers: + notes.append("پاسخ های تکمیلی کاربر دریافت شده است.") + + return { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": CORE_FIELDS, + "questions": self._build_generic_questions(CORE_FIELDS), + "collected_data": { + "crop_name": partial_plan.get("crop_name"), + "growth_stage": partial_plan.get("growth_stage"), + "objective": partial_plan.get("objective"), + "applications": applications, + "notes": notes, + }, + "final_plan": None, + } + + def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]: + catalog = { + "crop_name": { + "id": "crop_name", + "field": "crop_name", + "question": "این برنامه کودهی برای کدام محصول است؟", + "rationale": "نام محصول برای ثبت برنامه لازم است.", + }, + "growth_stage": { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای تکمیل برنامه لازم است.", + }, + "fertilizer_name": { + "id": "fertilizer_name", + "field": "fertilizer_name", + "question": "نام کود یا ترکیب کودی چیست؟", + "rationale": "بدون نام کود نمی توان برنامه را نهایی کرد.", + }, + "formula": { + "id": "formula", + "field": "formula", + "question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.", + "rationale": "ترکیب دقیق کود هنوز مشخص نشده است.", + }, + "amount": { + "id": "amount", + "field": "amount", + "question": "مقدار مصرف هر نوبت کود چقدر است؟", + "rationale": "دوز مصرف در متن مشخص نشده است.", + }, + "application_method": { + "id": "application_method", + "field": "application_method", + "question": "روش مصرف کود چیست؟ مثلا کودآبیاری، سرک یا محلول پاشی.", + "rationale": "روش اجرا هنوز معلوم نیست.", + }, + "timing": { + "id": "timing", + "field": "timing", + "question": "زمان مصرف کود چه موقع است؟ مثلا هر 10 روز یا بعد از آبیاری.", + "rationale": "زمان بندی برنامه نیاز به شفاف سازی دارد.", + }, + "interval_days": { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین نوبت های مصرف کود چند روز است؟", + "rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است.", + }, + } + return [catalog[field] for field in missing_fields if field in catalog][:5] + + def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]: + missing: list[str] = [] + if not isinstance(plan, dict): + return CORE_FIELDS[:] + + if plan.get("crop_name") in (None, ""): + missing.append("crop_name") + if plan.get("growth_stage") in (None, ""): + missing.append("growth_stage") + + applications = plan.get("applications") + if not isinstance(applications, list) or not applications: + return missing + [ + field + for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"] + if field not in missing + ] + + first_application = applications[0] if isinstance(applications[0], dict) else {} + for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]: + value = first_application.get(field) + if value is None or (isinstance(value, str) and not value.strip()): + missing.append(field) + return missing + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise ValueError("Fertilization plan parser response was empty.") + parsed = json.loads(cleaned) + if not isinstance(parsed, dict): + raise ValueError("Fertilization plan parser response root must be an object.") + return parsed diff --git a/Modules/Ai/rag/services/irrigation.py b/Modules/Ai/rag/services/irrigation.py new file mode 100644 index 0000000..a0b6b7d --- /dev/null +++ b/Modules/Ai/rag/services/irrigation.py @@ -0,0 +1,539 @@ +""" +سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویس‌ها +از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده می‌کند. +""" +import json +import logging +from typing import Any + +from django.apps import apps +from django.db import transaction + +from farm_data.models import SensorData +from farm_data.services import ( + clone_snapshot_as_runtime_plant, + get_farm_plant_snapshot_by_name, +) +from irrigation.evapotranspiration import ( + calculate_forecast_water_needs, + resolve_crop_profile, + resolve_kc, +) +from irrigation.models import IrrigationMethod +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.user_data import build_irrigation_method_text, build_plant_text +from weather.models import WeatherForecast + +logger = logging.getLogger(__name__) + +KB_NAME = "irrigation" +SERVICE_ID = "irrigation" + +DEFAULT_IRRIGATION_PROMPT = ( + "از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. " + "اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. " + "پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز." +) + + +def _get_optimizer(): + return apps.get_app_config("crop_simulation").get_recommendation_optimizer() + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None: + if sensor is None or not isinstance(sensor.sensor_payload, dict): + return None + for payload in sensor.sensor_payload.values(): + if isinstance(payload, dict) and payload.get(metric) is not None: + return _safe_float(payload.get(metric), default=0.0) + return None + + +def _coerce_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _coerce_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int: + normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0) + estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency)) + return max(10, min(estimated_minutes, 240)) + + +def _default_warning( + optimizer_result: dict[str, Any] | None, + daily_water_needs: list[dict[str, Any]], + soil_moisture: float | None, +) -> str: + strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy")) + reasoning = _coerce_list(strategy.get("reasoning")) + if reasoning: + return str(reasoning[0]) + if soil_moisture is not None and soil_moisture < 25: + return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد." + if soil_moisture is not None and soil_moisture > 80: + return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود." + if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs): + return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید." + return "در ساعات گرم روز آبیاری انجام نشود." + + +def _normalize_plan( + llm_result: dict[str, Any], + optimizer_result: dict[str, Any] | None, + daily_water_needs: list[dict[str, Any]], + irrigation_method: IrrigationMethod | None, + soil_moisture: float | None, +) -> dict[str, Any]: + llm_plan = _coerce_dict(llm_result.get("plan")) + strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy")) + + frequency = llm_plan.get("frequencyPerWeek") + if frequency is None: + frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1 + + duration = llm_plan.get("durationMinutes") + if duration is None: + duration = _estimate_duration_minutes( + _safe_float(strategy.get("amount_per_event_mm"), 6.0), + getattr(irrigation_method, "water_efficiency_percent", None), + ) + + best_time = llm_plan.get("bestTimeOfDay") + if not best_time: + best_time = strategy.get("timing") or ( + daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح" + ) + + moisture_level = llm_plan.get("moistureLevel") + if moisture_level is None: + moisture_level = round( + soil_moisture + if soil_moisture is not None + else _safe_float(strategy.get("moisture_target_percent"), 70.0) + ) + + warning = llm_plan.get("warning") + if not warning: + warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture) + + return { + "frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)), + "durationMinutes": int(max(_safe_float(duration, 10), 10)), + "bestTimeOfDay": str(best_time), + "moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)), + "warning": str(warning), + } + + +def _normalize_timeline( + llm_result: dict[str, Any], + optimizer_result: dict[str, Any] | None, + daily_water_needs: list[dict[str, Any]], +) -> list[dict[str, Any]]: + raw_timeline = _coerce_list(llm_result.get("timeline")) + timeline: list[dict[str, Any]] = [] + + for index, item in enumerate(raw_timeline, start=1): + item_dict = _coerce_dict(item) + title = item_dict.get("title") + description = item_dict.get("description") + if title and description: + timeline.append( + { + "step_number": int(item_dict.get("step_number") or index), + "title": str(title), + "description": str(description), + } + ) + + if timeline: + return timeline + + strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy")) + event_dates = _coerce_list(strategy.get("event_dates")) + best_timing = strategy.get("timing") or ( + daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود" + ) + + generated = [ + { + "step_number": 1, + "title": "بررسی فشار", + "description": "فشار ابتدا و انتهای لاین کنترل شود.", + }, + { + "step_number": 2, + "title": "اجرای آبیاری", + "description": f"آبیاری در بازه {best_timing} انجام شود.", + }, + ] + if event_dates: + generated.append( + { + "step_number": 3, + "title": "پیگیری برنامه", + "description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.", + } + ) + else: + generated.append( + { + "step_number": 3, + "title": "بازبینی رطوبت", + "description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.", + } + ) + return generated + + +def _normalize_sections( + llm_result: dict[str, Any], + optimizer_result: dict[str, Any] | None, + daily_water_needs: list[dict[str, Any]], + plan_warning: str, +) -> list[dict[str, Any]]: + raw_sections = _coerce_list(llm_result.get("sections")) + sections: list[dict[str, Any]] = [] + + for section in raw_sections: + item = _coerce_dict(section) + section_type = str(item.get("type") or "").strip().lower() + if section_type not in {"warning", "tip"}: + continue + content = item.get("content") + title = item.get("title") + if not content or not title: + continue + icon = item.get("icon") or ( + "tabler-alert-triangle" if section_type == "warning" else "tabler-bulb" + ) + sections.append( + { + "title": str(title), + "icon": str(icon), + "type": section_type, + "content": str(content), + } + ) + + if not any(item["type"] == "warning" for item in sections): + sections.insert( + 0, + { + "title": "هشدار آبیاری", + "icon": "tabler-alert-triangle", + "type": "warning", + "content": plan_warning, + }, + ) + + if not any(item["type"] == "tip" for item in sections): + strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy")) + reasoning = _coerce_list(strategy.get("reasoning")) + tip_content = ( + str(reasoning[-1]) + if reasoning + else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند." + ) + if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs): + tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید." + sections.append( + { + "title": "نکته بهره وری", + "icon": "tabler-bulb", + "type": "tip", + "content": tip_content, + } + ) + + return sections[:4] + + +def _build_irrigation_ui_payload( + llm_result: dict[str, Any], + optimizer_result: dict[str, Any] | None, + daily_water_needs: list[dict[str, Any]], + crop_profile: dict[str, Any], + active_kc: float, + irrigation_method: IrrigationMethod | None, + sensor: SensorData | None, +) -> dict[str, Any]: + soil_moisture = _sensor_metric(sensor, "soil_moisture") + plan = _normalize_plan( + llm_result, + optimizer_result, + daily_water_needs, + irrigation_method, + soil_moisture, + ) + payload = { + "plan": plan, + "water_balance": { + "daily": daily_water_needs, + "crop_profile": crop_profile, + "active_kc": active_kc, + }, + "timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs), + "sections": _normalize_sections( + llm_result, + optimizer_result, + daily_water_needs, + plan["warning"], + ), + } + return payload + + +def _resolve_irrigation_method( + sensor: SensorData | None, + irrigation_method_name: str | None, +) -> IrrigationMethod | None: + if irrigation_method_name: + return IrrigationMethod.objects.filter(name=irrigation_method_name).first() + if sensor is not None: + return sensor.irrigation_method + return None + + +def _persist_irrigation_method_on_farm( + sensor: SensorData | None, + irrigation_method: IrrigationMethod | None, +) -> None: + if sensor is None or irrigation_method is None: + return + if sensor.irrigation_method_id == irrigation_method.id: + return + + with transaction.atomic(): + sensor.irrigation_method = irrigation_method + sensor.save(update_fields=["irrigation_method", "updated_at"]) + + +def get_irrigation_recommendation( + farm_uuid: str | None = None, + plant_name: str | None = None, + growth_stage: str | None = None, + irrigation_method_name: str | None = None, + query: str | None = None, + config: RAGConfig | None = None, + limit: int = 8, + sensor_uuid: str | None = None, +) -> dict: + """ + توصیه آبیاری برای یک مزرعه. + از RAG با پایگاه دانش irrigation استفاده می‌کند. + + Args: + farm_uuid: شناسه مزرعه + plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant) + growth_stage: مرحله رشد گیاه + irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod) + query: سوال اختیاری + config: تنظیمات RAG + limit: تعداد چانک‌های بازیابی‌شده + + Returns: + dict ساختاریافته برای توصیه آبیاری + """ + cfg = config or load_rag_config() + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + model = service.llm.model + + resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip() + if not resolved_farm_uuid: + raise ValueError("farm_uuid is required.") + + user_query = query or "توصیه آبیاری برای مزرعه من چیست؟" + + sensor = ( + SensorData.objects.select_related("center_location", "irrigation_method") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=resolved_farm_uuid) + .first() + ) + irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name) + _persist_irrigation_method_on_farm(sensor, irrigation_method) + + plant = None + resolved_plant_name = plant_name + if sensor is not None: + selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name) + plant = clone_snapshot_as_runtime_plant( + selected_snapshot, + growth_stage=growth_stage, + ) + if selected_snapshot is not None: + resolved_plant_name = selected_snapshot.name + elif plant_name: + resolved_plant_name = plant_name + + crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage) + active_kc = resolve_kc(crop_profile, growth_stage=growth_stage) + forecasts = [] + daily_water_needs = [] + optimized_result = None + if sensor is not None: + forecasts = list( + WeatherForecast.objects.filter( + location=sensor.center_location, + forecast_date__isnull=False, + ).order_by("forecast_date")[:7] + ) + efficiency_percent = ( + getattr(irrigation_method, "water_efficiency_percent", None) + if irrigation_method + else None + ) + daily_water_needs = calculate_forecast_water_needs( + forecasts=forecasts, + latitude_deg=float(sensor.center_location.latitude), + crop_profile=crop_profile, + growth_stage=growth_stage, + irrigation_efficiency_percent=efficiency_percent, + ) + if plant is not None and forecasts: + optimized_result = _get_optimizer().optimize_irrigation( + sensor=sensor, + plant=plant, + forecasts=forecasts, + daily_water_needs=daily_water_needs, + growth_stage=growth_stage, + irrigation_method=irrigation_method, + ) + + context = build_rag_context( + user_query, + resolved_farm_uuid, + config=cfg, + limit=limit, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + + extra_parts: list[str] = [] + resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None + if resolved_plant_name and growth_stage: + plant_text = build_plant_text(resolved_plant_name, growth_stage) + if plant_text: + extra_parts.append("[اطلاعات گیاه]\n" + plant_text) + if resolved_irrigation_method_name: + method_text = build_irrigation_method_text(resolved_irrigation_method_name) + if method_text: + extra_parts.append("[روش آبیاری انتخابی]\n" + method_text) + if daily_water_needs: + total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2) + schedule_lines = [ + f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, " + f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, " + f"زمان پیشنهادی={item['irrigation_timing']}" + for item in daily_water_needs + ] + extra_parts.append( + "[خروجی قطعی محاسبات FAO-56]\n" + f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n" + f"Kc مورد استفاده: {active_kc}\n" + + "\n".join(schedule_lines) + ) + if optimized_result is not None: + extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"]) + if extra_parts: + context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") + + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(DEFAULT_IRRIGATION_PROMPT) + if context: + system_parts.append("\n\n" + context) + system_content = "\n".join(system_parts) + + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_query}, + ] + audit_log = _create_audit_log( + farm_uuid=resolved_farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_content, + messages=messages, + ) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + ) + raw = response.choices[0].message.content.strip() + except Exception as exc: + logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RuntimeError( + f"Irrigation recommendation failed for farm {resolved_farm_uuid}." + ) from exc + + try: + cleaned = raw + if cleaned.startswith("```"): + cleaned = cleaned.strip("`").removeprefix("json").strip() + llm_result = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + llm_result = {} + + result = _build_irrigation_ui_payload( + _coerce_dict(llm_result), + optimized_result, + daily_water_needs, + crop_profile, + active_kc, + irrigation_method, + sensor, + ) + result["raw_response"] = raw + result["simulation_optimizer"] = optimized_result + result["selected_irrigation_method"] = ( + { + "id": irrigation_method.id, + "name": irrigation_method.name, + "category": irrigation_method.category, + "water_efficiency_percent": irrigation_method.water_efficiency_percent, + } + if irrigation_method is not None + else None + ) + _complete_audit_log( + audit_log, + json.dumps(result, ensure_ascii=False, default=str), + ) + return result diff --git a/Modules/Ai/rag/services/irrigation_plan_parser.py b/Modules/Ai/rag/services/irrigation_plan_parser.py new file mode 100644 index 0000000..90e5349 --- /dev/null +++ b/Modules/Ai/rag/services/irrigation_plan_parser.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import json +import logging +from typing import Any, Literal + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config + +logger = logging.getLogger(__name__) + +SERVICE_ID = "irrigation_plan_parser" +KB_NAME = "irrigation_plan_parser" +CORE_FIELDS = [ + "crop_name", + "growth_stage", + "irrigation_method", + "water_amount_per_event", + "duration_minutes", + "frequency_text", + "interval_days", + "preferred_time_of_day", + "start_date", + "target_area", +] + +IRRIGATION_PLAN_PROMPT = ( + "شما یک تحلیل گر برنامه آبیاری هستی. " + "کاربر ممکن است برنامه آبیاری را کامل یا ناقص توضیح دهد. " + "وظیفه شما این است که فقط JSON معتبر برگردانی و متن اضافه، markdown، توضیح بیرون از JSON یا کلید اضافه تولید نکنی. " + "اگر اطلاعات کافی بود status را completed بگذار و final_plan را کامل کن. " + "اگر اطلاعات کافی نبود status را needs_clarification بگذار، missing_fields را پر کن و 1 تا 5 سوال کوتاه و دقیق در questions برگردان. " + "اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. " + "در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. " + "از حدس زدن جزئیات برنامه خودداری کن. " + "اگر کاربر فقط بخشی از سوالات قبلی را جواب داد، داده های جدید را با partial_plan ادغام کن و فقط سوالات باقی مانده را بپرس. " + "Schema: " + "{" + '"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": {same shape as collected_data} | null' + "}." +) + + +class ClarificationQuestionSchema(BaseModel): + id: str + field: str + question: str + rationale: str = "" + + +class IrrigationPlanSchema(BaseModel): + crop_name: str | None = None + growth_stage: str | None = None + irrigation_method: str | None = None + water_amount_per_event: str | None = None + duration_minutes: int | None = None + frequency_text: str | None = None + interval_days: int | None = None + preferred_time_of_day: str | None = None + start_date: str | None = None + target_area: str | None = None + trigger_conditions: list[str] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + + +class IrrigationPlanParseResultSchema(BaseModel): + status: Literal["completed", "needs_clarification"] + summary: str + missing_fields: list[str] = Field(default_factory=list) + questions: list[ClarificationQuestionSchema] = Field(default_factory=list) + collected_data: IrrigationPlanSchema = Field(default_factory=IrrigationPlanSchema) + final_plan: IrrigationPlanSchema | None = None + + +class IrrigationPlanParserService: + def parse_plan( + self, + *, + message: str = "", + answers: dict[str, Any] | None = None, + partial_plan: dict[str, Any] | None = None, + farm_uuid: str | None = None, + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + + normalized_message = (message or "").strip() + normalized_answers = answers if isinstance(answers, dict) else {} + normalized_partial = partial_plan if isinstance(partial_plan, dict) else {} + structured_context = { + "message": normalized_message, + "answers": normalized_answers, + "partial_plan": normalized_partial, + "required_core_fields": CORE_FIELDS, + "service": "irrigation_plan_parser", + } + + rag_query = self._build_retrieval_query( + message=normalized_message, + answers=normalized_answers, + ) + rag_context = build_rag_context( + query=rag_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + rag_context=rag_context, + ) + + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=rag_query, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Irrigation plan parser audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = IrrigationPlanParseResultSchema.model_validate(parsed) + normalized = self._normalize_result(validated) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return normalized + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Irrigation plan parser parsing failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + except Exception as exc: + logger.error("Irrigation plan parser failed: %s", exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + return self._fallback_result( + message=normalized_message, + answers=normalized_answers, + partial_plan=normalized_partial, + ) + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + rag_context: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(IRRIGATION_PLAN_PROMPT) + system_parts.append( + "[structured_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "برنامه آبیاری را استخراج یا برای تکمیل آن سوال بپرس."}, + ] + return system_prompt, messages + + def _build_retrieval_query( + self, + *, + message: str, + answers: dict[str, Any], + ) -> str: + answer_lines = [f"{key}: {value}" for key, value in answers.items()] + parts = [part for part in [message, "\n".join(answer_lines)] if part] + return "\n".join(parts) or "استخراج برنامه آبیاری از متن کاربر" + + def _normalize_result(self, validated: IrrigationPlanParseResultSchema) -> dict[str, Any]: + collected = validated.collected_data.model_dump() + final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None + missing_fields = list(dict.fromkeys(validated.missing_fields)) + computed_missing = self._find_missing_fields(final_plan or collected) + for field in computed_missing: + if field not in missing_fields: + missing_fields.append(field) + + can_complete = validated.status == "completed" and not missing_fields + + if can_complete: + final_plan = final_plan or collected + questions: list[dict[str, Any]] = [] + status_fa = "تکمیل شد" + else: + questions = [item.model_dump() for item in validated.questions] + if not questions and missing_fields: + questions = self._build_generic_questions(missing_fields) + final_plan = None + validated.status = "needs_clarification" + status_fa = "نیازمند پرسش تکمیلی" + + return { + "status": "completed" if can_complete else "needs_clarification", + "status_fa": status_fa, + "summary": validated.summary, + "missing_fields": missing_fields, + "questions": questions, + "collected_data": collected, + "final_plan": final_plan, + } + + def _fallback_result( + self, + *, + message: str, + answers: dict[str, Any], + partial_plan: dict[str, Any], + ) -> dict[str, Any]: + merged = dict(partial_plan) + notes = list(merged.get("notes") or []) + if message: + notes.append(f"متن اولیه کاربر: {message}") + for key, value in answers.items(): + merged.setdefault(key, value) + + return { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": CORE_FIELDS, + "questions": self._build_generic_questions(CORE_FIELDS), + "collected_data": { + "crop_name": merged.get("crop_name"), + "growth_stage": merged.get("growth_stage"), + "irrigation_method": merged.get("irrigation_method"), + "water_amount_per_event": merged.get("water_amount_per_event"), + "duration_minutes": merged.get("duration_minutes"), + "frequency_text": merged.get("frequency_text"), + "interval_days": merged.get("interval_days"), + "preferred_time_of_day": merged.get("preferred_time_of_day"), + "start_date": merged.get("start_date"), + "target_area": merged.get("target_area"), + "trigger_conditions": merged.get("trigger_conditions") or [], + "notes": notes, + }, + "final_plan": None, + } + + def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]: + catalog = { + "crop_name": { + "id": "crop_name", + "field": "crop_name", + "question": "این برنامه آبیاری برای کدام محصول است؟", + "rationale": "نام محصول برای ثبت برنامه لازم است.", + }, + "growth_stage": { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای کامل شدن برنامه لازم است.", + }, + "irrigation_method": { + "id": "irrigation_method", + "field": "irrigation_method", + "question": "روش آبیاری چیست؟ مثلا قطره ای، بارانی یا غرقابی.", + "rationale": "روش اجرا روی شکل برنامه تاثیر دارد.", + }, + "water_amount_per_event": { + "id": "water_amount_per_event", + "field": "water_amount_per_event", + "question": "در هر نوبت آبیاری چه مقدار آب داده می شود؟", + "rationale": "حجم یا عمق آب هر نوبت مشخص نشده است.", + }, + "duration_minutes": { + "id": "duration_minutes", + "field": "duration_minutes", + "question": "مدت زمان هر نوبت آبیاری چند دقیقه است؟", + "rationale": "مدت اجرای هر نوبت هنوز مشخص نیست.", + }, + "frequency_text": { + "id": "frequency_text", + "field": "frequency_text", + "question": "فاصله یا تعداد نوبت های آبیاری چگونه است؟ مثلا هر 3 روز یک بار.", + "rationale": "الگوی تکرار آبیاری باید مشخص باشد.", + }, + "interval_days": { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین دو آبیاری چند روز است؟", + "rationale": "عدد فاصله آبیاری برای JSON نهایی لازم است.", + }, + "preferred_time_of_day": { + "id": "preferred_time_of_day", + "field": "preferred_time_of_day", + "question": "بهترین زمان اجرای آبیاری چه موقع از روز است؟", + "rationale": "زمان اجرای برنامه هنوز معلوم نیست.", + }, + "start_date": { + "id": "start_date", + "field": "start_date", + "question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟", + "rationale": "زمان شروع برنامه هنوز مشخص نشده است.", + }, + "target_area": { + "id": "target_area", + "field": "target_area", + "question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟", + "rationale": "محدوده اجرای برنامه باید مشخص باشد.", + }, + } + return [catalog[field] for field in missing_fields if field in catalog][:5] + + def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]: + missing: list[str] = [] + for field in CORE_FIELDS: + value = plan.get(field) + if value is None: + missing.append(field) + continue + if isinstance(value, str) and not value.strip(): + missing.append(field) + return missing + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise ValueError("Irrigation plan parser response was empty.") + parsed = json.loads(cleaned) + if not isinstance(parsed, dict): + raise ValueError("Irrigation plan parser response root must be an object.") + return parsed diff --git a/Modules/Ai/rag/services/pest_disease.py b/Modules/Ai/rag/services/pest_disease.py new file mode 100644 index 0000000..5b71c66 --- /dev/null +++ b/Modules/Ai/rag/services/pest_disease.py @@ -0,0 +1,470 @@ +""" +سرویس RAG برای تشخیص تصویری و پیش بینی ریسک آفات و بیماری گیاه. +""" +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +from rag.api_provider import get_chat_client +from rag.chat import ( + _build_content_parts, + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError +from rag.user_data import build_plant_text + +logger = logging.getLogger(__name__) + +KB_NAME = "pest_disease" +SERVICE_ID = "pest_disease" + +DETECTION_PROMPT = ( + "شما یک دستیار تخصصی تشخیص آفات و بیماری گیاهی هستی. " + "با استفاده از تصویر، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش تحلیل کن. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: " + "has_issue, category, confidence, severity, summary, detected_signs, possible_causes, immediate_actions, reasoning. " + "category فقط یکی از no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown باشد. " + "severity فقط یکی از low, medium, high باشد." +) + +RISK_PROMPT = ( + "شما یک دستیار تخصصی پیش بینی ریسک آفات و بیماری گیاهی هستی. " + "با استفاده از داده های مزرعه، آب و هوا، مرحله رشد، و متن های بازیابی شده از پایگاه دانش تحلیل کن. " + "پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: " + "summary, forecast_window, overall_risk, disease_risk, pest_risk, key_drivers, recommended_actions. " + "overall_risk فقط یکی از low, medium, high باشد. " + "disease_risk و pest_risk باید آبجکت هایی با کلیدهای score, level, likely_conditions, reasoning باشند و level فقط یکی از low, medium, high باشد." +) + + +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 _normalize_images(images: list[dict[str, str]] | None) -> list[dict[str, str]]: + output: list[dict[str, str]] = [] + for item in images or []: + if not isinstance(item, dict): + continue + url = item.get("url") + if not isinstance(url, str) or not url.strip(): + continue + output.append({"url": url.strip(), "detail": item.get("detail", "auto")}) + return output + + +def _clean_json(raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise RAGServiceError( + error_code="empty_response", + message="Pest disease LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500]) + raise RAGServiceError( + error_code="invalid_json", + message="Pest disease LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease LLM response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) + return farm_details + + +def _build_service_client(cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + +def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]: + weather = farm_details.get("weather") or {} + soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {} + humidity = _safe_float(weather.get("humidity_mean"), 55.0) + temp = _safe_float(weather.get("temperature_mean"), 24.0) + rain = _safe_float(weather.get("precipitation"), 0.0) + moisture = _safe_float(soil.get("soil_moisture"), _safe_float(soil.get("wv0033"), 35.0)) + ec = _safe_float(soil.get("electrical_conductivity"), 0.0) + ph = _safe_float(soil.get("soil_ph") or soil.get("phh2o"), 7.0) + + fungal_score = min(max(round((humidity * 0.45) + (moisture * 0.35) + (rain * 2.5) - 25, 2), 0.0), 100.0) + pest_score = min(max(round((temp * 2.2) + max(0.0, 45.0 - moisture) + (ec * 3.0) - 20, 2), 0.0), 100.0) + abiotic_stress = min(max(round((abs(ph - 6.8) * 18.0) + (ec * 8.0), 2), 0.0), 100.0) + return { + "humidity_mean": humidity, + "temperature_mean": temp, + "precipitation": rain, + "soil_moisture": moisture, + "ec": ec, + "ph": ph, + "fungal_score": fungal_score, + "pest_score": pest_score, + "abiotic_stress_score": abiotic_stress, + } + + +def _risk_level(score: float) -> str: + if score >= 70: + return "high" + if score >= 40: + return "medium" + return "low" + + +def _build_risk_context(farm_details: dict[str, Any], plant_name: str | None, growth_stage: str | None) -> dict[str, Any]: + risk = _weather_risk_summary(farm_details) + disease_level = _risk_level(risk["fungal_score"]) + pest_level = _risk_level(risk["pest_score"]) + overall_score = max(risk["fungal_score"], risk["pest_score"], risk["abiotic_stress_score"]) + overall_level = _risk_level(overall_score) + drivers = [] + if risk["humidity_mean"] >= 70: + drivers.append("رطوبت بالا") + if risk["soil_moisture"] >= 60: + drivers.append("رطوبت خاک بالا") + if risk["temperature_mean"] >= 30: + drivers.append("دمای بالا") + if risk["precipitation"] > 2: + drivers.append("بارش موثر") + if risk["ec"] > 2.5: + drivers.append("EC بالا") + if abs(risk["ph"] - 6.8) > 0.8: + drivers.append("خروج pH از محدوده مطلوب") + if not drivers: + drivers.append("شرایط فعلی مزرعه نسبتا پایدار است") + + return { + "summary": "برآورد ریسک آفات و بیماری بر اساس داده های فعلی مزرعه ساخته شد.", + "forecast_window": "24 تا 72 ساعت آینده", + "overall_risk": overall_level, + "disease_risk": { + "score": risk["fungal_score"], + "level": disease_level, + "likely_conditions": [ + "فشار قارچی و بیماری برگی" if disease_level != "low" else "ریسک بیماری فعلا پایین است", + ], + "reasoning": [ + f"رطوبت میانگین حدود {risk['humidity_mean']} درصد است.", + f"رطوبت خاک حدود {risk['soil_moisture']} درصد برآورد شده است.", + ], + }, + "pest_risk": { + "score": risk["pest_score"], + "level": pest_level, + "likely_conditions": [ + "فشار آفات مکنده یا تنش زا" if pest_level != "low" else "ریسک آفت فعلا پایین است", + ], + "reasoning": [ + f"دمای میانگین حدود {risk['temperature_mean']} درجه است.", + f"EC فعلی حدود {risk['ec']} و pH حدود {risk['ph']} است.", + ], + }, + "key_drivers": drivers, + "recommended_actions": [ + "بازدید مزرعه و بررسی برگ ها و پشت برگ انجام شود.", + "در صورت مشاهده علائم مشکوک، نمونه برداری تصویری نزدیک تر انجام شود.", + "رطوبت ماندگار و یکنواختی آبیاری پایش شود.", + ], + "farm_context": { + "plant_name": plant_name, + "growth_stage": growth_stage, + "risk_summary": risk, + }, + } + + +def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]: + required_keys = { + "has_issue", + "category", + "confidence", + "severity", + "summary", + "detected_signs", + "possible_causes", + "immediate_actions", + "reasoning", + } + missing = [key for key in required_keys if key not in parsed] + if missing: + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease detection response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]: + required_keys = { + "summary", + "forecast_window", + "overall_risk", + "disease_risk", + "pest_risk", + "key_drivers", + "recommended_actions", + } + missing = [key for key in required_keys if key not in parsed] + if missing: + raise RAGServiceError( + error_code="invalid_schema", + message="Pest disease risk response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _build_detection_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + plant_text: str, + images: list[dict[str, str]], +) -> tuple[str, list[dict[str, Any]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(DETECTION_PROMPT) + if plant_text: + system_parts.append("[اطلاعات گیاه]\n" + plant_text) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": _build_content_parts(query, images)}, + ] + return system_prompt, messages + + +def _build_risk_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + structured_context: dict[str, Any], + plant_text: str, +) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(RISK_PROMPT) + if plant_text: + system_parts.append("[اطلاعات گیاه]\n" + plant_text) + system_parts.append("[کانتکست ساختاریافته ریسک]\n" + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + +def get_pest_disease_detection( + *, + farm_uuid: str, + plant_name: str | None = None, + query: str | None = None, + images: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + normalized_images = _normalize_images(images) + if not normalized_images: + raise RAGServiceError( + error_code="missing_images", + message="حداقل یک تصویر برای تشخیص لازم است.", + source="request", + http_status=400, + ) + + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name") + user_query = query or "این تصویر را بررسی کن و بگو آیا گیاه دچار آفت یا بیماری شده است یا نه." + plant_text = build_plant_text(resolved_plant_name, "") if resolved_plant_name else "" + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_detection_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + plant_text=plant_text or "", + images=normalized_images, + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_prompt, + messages=messages, + ) + try: + response = client.chat.completions.create(model=model, messages=messages) + raw = response.choices[0].message.content.strip() + parsed = _clean_json(raw) + _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise + except Exception as exc: + logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="upstream_failure", + message=f"Pest disease detection failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc + + parsed = _validate_detection_result(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" + parsed["farm_uuid"] = farm_uuid + parsed["raw_response"] = raw + return parsed + + +def get_pest_disease_risk( + *, + farm_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name") + risk_context = _build_risk_context(farm_details, resolved_plant_name, growth_stage) + user_query = query or "ریسک آفات و بیماری این مزرعه را برای چند روز آینده پیش بینی کن." + plant_text = build_plant_text(resolved_plant_name, growth_stage or "") if resolved_plant_name else "" + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_risk_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + structured_context=risk_context, + plant_text=plant_text or "", + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_prompt, + messages=messages, + ) + try: + response = client.chat.completions.create(model=model, messages=messages) + raw = response.choices[0].message.content.strip() + parsed = _clean_json(raw) + _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise + except Exception as exc: + logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="upstream_failure", + message=f"Pest disease risk prediction failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc + + parsed = _validate_risk_result(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" + parsed["farm_uuid"] = farm_uuid + parsed["raw_response"] = raw + return parsed diff --git a/Modules/Ai/rag/services/soil_anomaly.py b/Modules/Ai/rag/services/soil_anomaly.py new file mode 100644 index 0000000..11fb364 --- /dev/null +++ b/Modules/Ai/rag/services/soil_anomaly.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError + +logger = logging.getLogger(__name__) + +KB_NAME = "soil_anomaly" +SERVICE_ID = "soil_anomaly" + +SOIL_ANOMALY_PROMPT = ( + "شما یک دستیار تخصصی تحلیل ناهنجاری داده های خاک و سنسور مزرعه هستی. " + "ورودی شامل داده های ساختاریافته ناهنجاری، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش است. " + "فقط JSON معتبر برگردان و فقط این کلیدها را تولید کن: " + "summary, explanation, likely_cause, recommended_action, monitoring_priority, confidence. " + "monitoring_priority فقط یکی از low, medium, high, urgent باشد. " + "confidence عددی بین 0 و 1 باشد. " + "اگر ناهنجاری معناداری وجود ندارد، این موضوع را شفاف و بدون اغراق بیان کن." +) + + +def _clean_json(raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise RAGServiceError( + error_code="empty_response", + message="Soil anomaly LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500]) + raise RAGServiceError( + error_code="invalid_json", + message="Soil anomaly LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Soil anomaly LLM response root must be a JSON object.", + source="llm", + retriable=False, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) + return farm_details + + +def _build_service_client(cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + +def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]: + required_keys = { + "summary", + "explanation", + "likely_cause", + "recommended_action", + "monitoring_priority", + "confidence", + } + missing = [key for key in required_keys if key not in parsed] + if missing: + raise RAGServiceError( + error_code="invalid_schema", + message="Soil anomaly insight response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _build_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + structured_context: dict[str, Any], +) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(SOIL_ANOMALY_PROMPT) + system_parts.append( + "[کانتکست ساختاریافته ناهنجاري خاک]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + +def get_soil_anomaly_insight( + *, + farm_uuid: str, + anomaly_payload: dict[str, Any], + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + user_query = query or "ناهنجاري هاي داده هاي خاک اين مزرعه را تفسير کن و اقدام مناسب پيشنهاد بده." + structured_context = { + "farm_uuid": farm_uuid, + "anomaly_payload": anomaly_payload, + } + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + structured_context=structured_context, + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_prompt, + messages=messages, + ) + try: + response = client.chat.completions.create(model=model, messages=messages) + raw = response.choices[0].message.content.strip() + parsed = _clean_json(raw) + _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise + except Exception as exc: + logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="upstream_failure", + message=f"Soil anomaly insight failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc + + parsed = _validate_anomaly_insight(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" + parsed["farm_uuid"] = farm_uuid + parsed["raw_response"] = raw + return parsed diff --git a/Modules/Ai/rag/services/water_need_prediction.py b/Modules/Ai/rag/services/water_need_prediction.py new file mode 100644 index 0000000..ce93745 --- /dev/null +++ b/Modules/Ai/rag/services/water_need_prediction.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from farm_data.services import get_farm_details +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, + build_rag_context, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError + +logger = logging.getLogger(__name__) + +KB_NAME = "water_need_prediction" +SERVICE_ID = "water_need_prediction" + +WATER_NEED_PROMPT = ( + "شما یک دستیار تخصصی تحليل نياز آبي کوتاه مدت مزرعه هستي. " + "ورودي شامل محاسبات ساختاريافته نياز آبي، اطلاعات مزرعه و متن هاي بازيابي شده از پايگاه دانش است. " + "فقط JSON معتبر با اين کليدها برگردان: " + "summary, irrigation_outlook, recommended_action, risk_note, confidence. " + "confidence عددي بين 0 و 1 باشد. " + "اعداد اصلي را از داده ورودي بگير و عدد متناقض جديد نساز." +) + + +def _clean_json(raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise RAGServiceError( + error_code="empty_response", + message="Water need prediction LLM response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500]) + raise RAGServiceError( + error_code="invalid_json", + message="Water need prediction LLM response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Water need prediction LLM response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + raise RAGServiceError( + error_code="farm_not_found", + message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.", + source="farm_data", + details={"farm_uuid": farm_uuid}, + http_status=404, + ) + return farm_details + + +def _build_service_client(cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + +def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]: + required_keys = { + "summary", + "irrigation_outlook", + "recommended_action", + "risk_note", + "confidence", + } + missing = [key for key in required_keys if key not in parsed] + if missing: + raise RAGServiceError( + error_code="invalid_schema", + message="Water need prediction insight response is missing required fields: " + ", ".join(missing), + source="llm", + details={"missing_fields": missing, "service_id": SERVICE_ID}, + http_status=502, + ) + return parsed + + +def _build_messages( + *, + service: Any, + cfg: RAGConfig, + query: str, + rag_context: str, + structured_context: dict[str, Any], +) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(WATER_NEED_PROMPT) + system_parts.append( + "[کانتکست ساختاريافته نياز آبي]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + if rag_context: + system_parts.append(rag_context) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + +def get_water_need_prediction_insight( + *, + farm_uuid: str, + prediction_payload: dict[str, Any], + query: str | None = None, +) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = _build_service_client(cfg) + farm_details = _load_farm_or_error(farm_uuid) + user_query = query or "نياز آبي کوتاه مدت اين مزرعه را تفسير کن و اقدام عملياتي پيشنهاد بده." + structured_context = { + "farm_uuid": farm_uuid, + "prediction_payload": prediction_payload, + } + rag_context = build_rag_context( + query=user_query, + sensor_uuid=farm_uuid, + config=cfg, + kb_name=KB_NAME, + service_id=SERVICE_ID, + farm_details=farm_details, + ) + system_prompt, messages = _build_messages( + service=service, + cfg=cfg, + query=user_query, + rag_context=rag_context, + structured_context=structured_context, + ) + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_query, + system_prompt=system_prompt, + messages=messages, + ) + try: + response = client.chat.completions.create(model=model, messages=messages) + raw = response.choices[0].message.content.strip() + parsed = _clean_json(raw) + _complete_audit_log(audit_log, raw) + except RAGServiceError as exc: + logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise + except Exception as exc: + logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="upstream_failure", + message=f"Water need prediction insight failed for farm {farm_uuid}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID}, + http_status=503, + ) from exc + + parsed = _validate_prediction_insight(parsed) + parsed["status"] = "success" + parsed["source"] = "llm" + parsed["farm_uuid"] = farm_uuid + parsed["raw_response"] = raw + return parsed diff --git a/Modules/Ai/rag/services/yield_harvest.py b/Modules/Ai/rag/services/yield_harvest.py new file mode 100644 index 0000000..989d118 --- /dev/null +++ b/Modules/Ai/rag/services/yield_harvest.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from pydantic import BaseModel, Field, ValidationError + +from rag.api_provider import get_chat_client +from rag.chat import ( + _complete_audit_log, + _create_audit_log, + _fail_audit_log, + _load_service_tone, +) +from rag.config import RAGConfig, get_service_config, load_rag_config +from rag.failure_contract import RAGServiceError + +logger = logging.getLogger(__name__) + +SERVICE_ID = "yield_harvest" + +YIELD_HARVEST_PROMPT = ( + "You are an expert agronomist writing concise dashboard narratives for farmers. " + "Return only valid JSON matching this schema exactly: " + "{" + '"season_highlights_subtitle": string, ' + '"yield_prediction_explanation": string, ' + '"harvest_readiness_summary": string, ' + '"operation_notes": [string, ...]' + "}. " + "Do not add markdown, explanations, or extra keys. " + "Strict Golden Rule: do not invent numbers, dates, prices, revenues, percentages, KPIs, scores, or measurements. " + "Use only values already present in the deterministic context. " + "If a fact is missing from the context, say less rather than guessing." +) + + +class YieldHarvestNarrativeSchema(BaseModel): + season_highlights_subtitle: str + yield_prediction_explanation: str + harvest_readiness_summary: str + operation_notes: list[str] = Field(default_factory=list) + + +class YieldHarvestRAGService: + def generate_narrative( + self, + deterministic_context: dict[str, Any], + ) -> dict[str, Any]: + cfg = load_rag_config() + service, client, model = self._build_service_client(cfg) + structured_context = self._build_structured_context( + deterministic_context=deterministic_context, + ) + user_prompt = ( + "Generate short user-friendly narrative fields for the Yield & Harvest Summary dashboard " + "using only the deterministic context. Keep the language practical and agronomy-focused." + ) + system_prompt, messages = self._build_messages( + service=service, + cfg=cfg, + structured_context=structured_context, + query=user_prompt, + ) + + farm_uuid = str(deterministic_context.get("farm_uuid") or "") + audit_log = None + if farm_uuid: + try: + audit_log = _create_audit_log( + farm_uuid=farm_uuid, + service_id=SERVICE_ID, + model=model, + query=user_prompt, + system_prompt=system_prompt, + messages=messages, + ) + except Exception as exc: + logger.warning("Yield harvest audit log creation failed for %s: %s", farm_uuid, exc) + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + response_format={"type": "json_object"}, + ) + raw = (response.choices[0].message.content or "").strip() + parsed = self._clean_json(raw) + validated = YieldHarvestNarrativeSchema.model_validate(parsed) + if audit_log is not None: + _complete_audit_log(audit_log, raw) + return { + "status": "success", + "source": "llm", + "season_highlights_subtitle": validated.season_highlights_subtitle, + "yield_prediction_explanation": validated.yield_prediction_explanation, + "harvest_readiness_summary": validated.harvest_readiness_summary, + "operation_notes": validated.operation_notes, + } + except (ValidationError, ValueError, KeyError, IndexError) as exc: + logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="invalid_payload", + message=f"Yield harvest narrative parsing failed for farm_uuid={farm_uuid or 'unknown'}.", + source="llm", + details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID}, + http_status=502, + ) from exc + except Exception as exc: + logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc) + if audit_log is not None: + _fail_audit_log(audit_log, str(exc)) + raise RAGServiceError( + error_code="upstream_failure", + message=f"Yield harvest narrative generation failed for farm_uuid={farm_uuid or 'unknown'}.", + source="llm", + retriable=True, + details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID}, + http_status=503, + ) from exc + + def _build_service_client(self, cfg: RAGConfig): + service = get_service_config(SERVICE_ID, cfg) + service_cfg = RAGConfig( + embedding=cfg.embedding, + qdrant=cfg.qdrant, + chunking=cfg.chunking, + llm=service.llm, + knowledge_bases=cfg.knowledge_bases, + services=cfg.services, + chromadb=cfg.chromadb, + ) + client = get_chat_client(service_cfg) + return service, client, service.llm.model + + def _build_messages( + self, + *, + service: Any, + cfg: RAGConfig, + structured_context: dict[str, Any], + query: str, + ) -> tuple[str, list[dict[str, str]]]: + tone = _load_service_tone(service, cfg) + system_parts = [tone] if tone else [] + if service.system_prompt: + system_parts.append(service.system_prompt) + system_parts.append(YIELD_HARVEST_PROMPT) + system_parts.append( + "[deterministic_context]\n" + + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str) + ) + system_prompt = "\n\n".join(part for part in system_parts if part) + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": query}, + ] + return system_prompt, messages + + def _build_structured_context( + self, + *, + deterministic_context: dict[str, Any], + ) -> dict[str, Any]: + season = deterministic_context.get("season_highlights_card") or {} + harvest = deterministic_context.get("harvest_prediction_card") or {} + operations = deterministic_context.get("harvest_operations_card") or {} + yield_prediction = deterministic_context.get("yield_prediction") or {} + readiness = deterministic_context.get("harvest_readiness_zones") or {} + + operation_steps = [] + for step in operations.get("steps") or []: + if not isinstance(step, dict): + continue + operation_steps.append( + { + "key": step.get("key"), + "title": step.get("title"), + "status": step.get("status"), + } + ) + + return { + "farm_context": deterministic_context.get("farm_context") or {}, + "yield_prediction": { + "predicted_yield_tons": yield_prediction.get("predicted_yield_tons"), + "unit": yield_prediction.get("unit"), + "simulation_warning": yield_prediction.get("simulation_warning"), + "supporting_metrics": yield_prediction.get("supporting_metrics"), + }, + "season_highlights_card": { + "title": season.get("title"), + "subtitle": season.get("subtitle"), + "total_predicted_yield": season.get("total_predicted_yield"), + "yield_unit": season.get("yield_unit"), + "target_harvest_date": season.get("target_harvest_date"), + "days_until_harvest": season.get("days_until_harvest"), + "average_readiness": season.get("average_readiness"), + "primary_quality_grade": season.get("primary_quality_grade"), + "estimated_revenue": season.get("estimated_revenue"), + }, + "harvest_prediction_card": { + "harvest_date": harvest.get("harvest_date"), + "harvest_date_formatted": harvest.get("harvest_date_formatted"), + "days_until": harvest.get("days_until"), + "optimal_window_start": harvest.get("optimal_window_start"), + "optimal_window_end": harvest.get("optimal_window_end"), + "description": harvest.get("description"), + }, + "harvest_readiness_zones": { + "average_readiness": readiness.get("averageReadiness"), + "mean_ndvi": readiness.get("meanNdvi"), + "ndvi_trend": readiness.get("ndviTrend"), + "zones": readiness.get("zones"), + }, + "harvest_operations_card": { + "stage_label": operations.get("stage_label"), + "days_until_harvest": operations.get("days_until_harvest"), + "current_dvs": operations.get("current_dvs"), + "summary": operations.get("summary"), + "steps": operation_steps, + }, + } + + def _clean_json(self, raw: str) -> dict[str, Any]: + cleaned = (raw or "").strip() + if cleaned.startswith("```"): + cleaned = cleaned.strip("`") + if cleaned.startswith("json"): + cleaned = cleaned[4:] + cleaned = cleaned.strip() + if not cleaned: + raise RAGServiceError( + error_code="empty_response", + message="Yield harvest narrative response was empty.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) + try: + parsed = json.loads(cleaned) + except (json.JSONDecodeError, ValueError) as exc: + raise RAGServiceError( + error_code="invalid_json", + message="Yield harvest narrative response was not valid JSON.", + source="llm", + retriable=True, + details={"service_id": SERVICE_ID}, + http_status=502, + ) from exc + if not isinstance(parsed, dict): + raise RAGServiceError( + error_code="invalid_schema", + message="Yield harvest narrative response root must be a JSON object.", + source="llm", + details={"service_id": SERVICE_ID}, + http_status=502, + ) + return parsed diff --git a/Modules/Ai/rag/tasks.py b/Modules/Ai/rag/tasks.py new file mode 100644 index 0000000..5b05e4f --- /dev/null +++ b/Modules/Ai/rag/tasks.py @@ -0,0 +1,77 @@ +""" +تسک‌های Celery برای RAG +""" +from config.celery import app + +from .ingest import ingest + + +@app.task +def rag_ingest_task(recreate: bool = True): + """ + embed و ذخیره دیتای همه کاربران در Qdrant. + هر چند ساعت یکبار اجرا شود (از طریق Celery Beat). + recreate=True: collection از نو ساخته می‌شود تا دیتای قدیمی حذف شود. + """ + result = ingest(recreate=recreate) + return result + + +@app.task(bind=True) +def irrigation_recommendation_task( + self, + farm_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + irrigation_method_name: str | None = None, + query: str | None = None, +) -> dict: + """ + تسک Celery برای تولید توصیه آبیاری. + داده‌های سنسور، گیاه و روش آبیاری را از DB بارگذاری کرده + و از سرویس RAG توصیه می‌گیرد. + """ + from rag.services.irrigation import get_irrigation_recommendation + + self.update_state( + state="PROGRESS", + meta={"message": "در حال پردازش توصیه آبیاری..."}, + ) + result = get_irrigation_recommendation( + farm_uuid=farm_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + irrigation_method_name=irrigation_method_name, + query=query, + ) + result["status"] = "completed" + return result + + +@app.task(bind=True) +def fertilization_recommendation_task( + self, + farm_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + query: str | None = None, +) -> dict: + """ + تسک Celery برای تولید توصیه کودهی. + داده‌های سنسور و گیاه را از DB بارگذاری کرده + و از سرویس RAG توصیه می‌گیرد. + """ + from rag.services.fertilization import get_fertilization_recommendation + + self.update_state( + state="PROGRESS", + meta={"message": "در حال پردازش توصیه کودهی..."}, + ) + result = get_fertilization_recommendation( + farm_uuid=farm_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + query=query, + ) + result["status"] = "completed" + return result diff --git a/Modules/Ai/rag/tests/test_chat_context.py b/Modules/Ai/rag/tests/test_chat_context.py new file mode 100644 index 0000000..30e4b7c --- /dev/null +++ b/Modules/Ai/rag/tests/test_chat_context.py @@ -0,0 +1,71 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase + +from rag.chat import _normalize_history_messages, build_rag_context + + +class ChatContextTests(SimpleTestCase): + @patch("rag.chat.search_with_texts") + @patch("rag.chat.chunk_text") + def test_build_rag_context_includes_full_farm_and_kb_results( + self, + mock_chunk_text, + mock_search_with_texts, + ): + mock_chunk_text.return_value = ["farm chunk 1", "farm chunk 2"] + mock_search_with_texts.return_value = [ + {"id": "kb-1", "score": 0.8, "text": "kb text 1", "metadata": {}}, + {"id": "kb-2", "score": 0.7, "text": "kb text 2", "metadata": {}}, + ] + + context = build_rag_context( + query="وضعیت مزرعه چطور است؟", + sensor_uuid="farm-123", + service_id="chat", + farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}}, + ) + + self.assertIn("[اطلاعات کامل مزرعه]", context) + self.assertIn("soil_moisture", context) + self.assertIn("[متن‌های مرجع]", context) + self.assertIn("kb text 1", context) + self.assertIn("kb text 2", context) + mock_search_with_texts.assert_called_once() + sent_texts = mock_search_with_texts.call_args.kwargs["texts"] + self.assertEqual(sent_texts[0], "وضعیت مزرعه چطور است؟") + self.assertIn("farm chunk 1", sent_texts) + self.assertIn("farm chunk 2", sent_texts) + + def test_normalize_history_messages_supports_user_images(self): + messages = _normalize_history_messages( + [ + {"role": "user", "content": "این تصویر مزرعه است", "image_urls": ["https://example.com/a.jpg"]}, + {"role": "assistant", "content": "تصویر دریافت شد."}, + ] + ) + + self.assertEqual(len(messages), 2) + self.assertEqual(messages[0]["role"], "user") + self.assertIsInstance(messages[0]["content"], list) + self.assertEqual(messages[0]["content"][0]["type"], "text") + self.assertEqual(messages[0]["content"][1]["type"], "image_url") + self.assertEqual(messages[1]["role"], "assistant") + self.assertEqual(messages[1]["content"], "تصویر دریافت شد.") + + @patch("rag.chat.search_with_texts", return_value=[]) + @patch("rag.chat.chunk_text", return_value=["farm chunk"]) + def test_build_rag_context_returns_full_farm_when_kb_empty( + self, + _mock_chunk_text, + _mock_search_with_texts, + ): + context = build_rag_context( + query="رطوبت چقدر است؟", + sensor_uuid="farm-123", + service_id="chat", + farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}}, + ) + + self.assertIn("[اطلاعات کامل مزرعه]", context) + self.assertIn("soil_moisture", context) diff --git a/Modules/Ai/rag/tests/test_failure_contracts.py b/Modules/Ai/rag/tests/test_failure_contracts.py new file mode 100644 index 0000000..135d557 --- /dev/null +++ b/Modules/Ai/rag/tests/test_failure_contracts.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from rag.failure_contract import RAGServiceError +from rag.services.pest_disease import get_pest_disease_detection +from rag.services.soil_anomaly import get_soil_anomaly_insight +from rag.services.water_need_prediction import get_water_need_prediction_insight +from rag.services.yield_harvest import YieldHarvestRAGService + + +class RAGFailureContractTests(SimpleTestCase): + @patch("rag.services.soil_anomaly._create_audit_log", return_value=object()) + @patch("rag.services.soil_anomaly._fail_audit_log") + @patch("rag.services.soil_anomaly._build_service_client") + @patch("rag.services.soil_anomaly.build_rag_context", return_value="") + @patch("rag.services.soil_anomaly._load_farm_or_error", return_value={"farm_uuid": "farm-1"}) + def test_soil_anomaly_invalid_json_raises_structured_error( + self, + _mock_load_farm, + _mock_context, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + get_soil_anomaly_insight(farm_uuid="farm-1", anomaly_payload={}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") + + @patch("rag.services.water_need_prediction._create_audit_log", return_value=object()) + @patch("rag.services.water_need_prediction._fail_audit_log") + @patch("rag.services.water_need_prediction._build_service_client") + @patch("rag.services.water_need_prediction.build_rag_context", return_value="") + @patch("rag.services.water_need_prediction._load_farm_or_error", return_value={"farm_uuid": "farm-1"}) + def test_water_need_invalid_json_raises_structured_error( + self, + _mock_load_farm, + _mock_context, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + get_water_need_prediction_insight(farm_uuid="farm-1", prediction_payload={}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") + + def test_pest_detection_requires_image_with_structured_error(self): + with self.assertRaises(RAGServiceError) as exc_info: + get_pest_disease_detection(farm_uuid="farm-1", images=[]) + + self.assertEqual(exc_info.exception.contract.error_code, "missing_images") + + @patch("rag.services.yield_harvest._create_audit_log", return_value=object()) + @patch("rag.services.yield_harvest._fail_audit_log") + @patch("rag.services.yield_harvest.YieldHarvestRAGService._build_service_client") + def test_yield_harvest_invalid_json_raises_structured_error( + self, + mock_build_client, + _mock_fail, + _mock_audit, + ): + client = Mock() + client.chat.completions.create.return_value = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))] + ) + mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test") + + with self.assertRaises(RAGServiceError) as exc_info: + YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"}) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_json") diff --git a/Modules/Ai/rag/tests/test_observability.py b/Modules/Ai/rag/tests/test_observability.py new file mode 100644 index 0000000..1a38d49 --- /dev/null +++ b/Modules/Ai/rag/tests/test_observability.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.test import SimpleTestCase + +from rag.embedding import embed_texts +from rag.ingest import ingest +from rag.observability import METRICS +from rag.retrieve import search_with_query + + +class RAGObservabilityTests(SimpleTestCase): + def tearDown(self): + METRICS.clear() + + def test_embed_texts_records_empty_input_metric(self): + result = embed_texts([]) + + self.assertEqual(result, []) + self.assertEqual(METRICS["rag.embedding.empty_input|operation=embed_texts"], 1) + + @patch("rag.retrieve.QdrantVectorStore") + @patch("rag.retrieve.embed_single", return_value=[0.1, 0.2]) + @patch("rag.retrieve.load_rag_config") + def test_search_with_query_records_empty_result_metric(self, mock_load_config, _mock_embed, mock_store_cls): + mock_load_config.return_value = SimpleNamespace( + embedding=SimpleNamespace(provider="gapgpt"), + ) + mock_store = Mock() + mock_store.search.return_value = [] + mock_store_cls.return_value = mock_store + + result = search_with_query("query") + + self.assertEqual(result, []) + self.assertEqual(METRICS["rag.retrieve.empty_result|operation=search_with_query,service_id=None"], 1) + + @patch("rag.ingest.load_sources", return_value=[]) + @patch("rag.ingest.QdrantVectorStore") + @patch("rag.ingest.load_rag_config") + def test_ingest_records_empty_sources_metric(self, mock_load_config, _mock_store_cls, _mock_sources): + mock_load_config.return_value = SimpleNamespace( + embedding=SimpleNamespace(provider="gapgpt"), + ) + + result = ingest() + + self.assertEqual(result["chunks_added"], 0) + self.assertEqual(METRICS["rag.ingest.empty_sources|kb_name=None"], 1) diff --git a/Modules/Ai/rag/tests/test_recommendation_services.py b/Modules/Ai/rag/tests/test_recommendation_services.py new file mode 100644 index 0000000..f7184e4 --- /dev/null +++ b/Modules/Ai/rag/tests/test_recommendation_services.py @@ -0,0 +1,387 @@ +import uuid +from datetime import date +from unittest.mock import Mock, patch + +from django.test import TestCase + +from farm_data.models import PlantCatalogSnapshot, SensorData +from farm_data.services import assign_farm_plants_from_backend_ids +from irrigation.models import IrrigationMethod +from location_data.models import SoilLocation +from rag.services.fertilization import get_fertilization_recommendation +from rag.services.irrigation import get_irrigation_recommendation +from weather.models import WeatherForecast + + +class RecommendationServiceDefaultsTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create( + latitude="35.700000", + longitude="51.400000", + farm_boundary={"type": "Polygon", "coordinates": []}, + ) + WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=23.0, + temperature_mean=18.0, + ) + self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی") + self.onion = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="پیاز") + self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") + self.farm_uuid = uuid.uuid4() + self.farm = SensorData.objects.create( + farm_uuid=self.farm_uuid, + center_location=self.location, + irrigation_method=self.irrigation_method, + sensor_payload={ + "sensor-7-1": { + "soil_moisture": 30.0, + "nitrogen": 18.0, + "phosphorus": 12.0, + "potassium": 14.0, + "soil_ph": 6.9, + } + }, + ) + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id]) + + def build_irrigation_optimizer_result(self): + return { + "engine": "crop_simulation_heuristic", + "context_text": "optimizer irrigation context", + "recommended_strategy": { + "code": "balanced", + "label": "آبیاری متعادل", + "score": 88.0, + "expected_yield_index": 91.0, + "total_irrigation_mm": 24.0, + "amount_per_event_mm": 8.0, + "events": 3, + "frequency_per_week": 3, + "event_dates": ["2026-04-10"], + "timing": "اوایل صبح", + "moisture_target_percent": 70, + "validity_period": "معتبر برای 3 روز آینده", + "reasoning": ["شبیه ساز این سناریو را برتر ارزیابی کرد."], + }, + "alternatives": [ + { + "code": "protective", + "label": "آبیاری حمایتی", + "score": 80.0, + "expected_yield_index": 85.0, + "total_irrigation_mm": 28.0, + } + ], + } + + def build_irrigation_llm_result(self): + return ( + '{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", ' + '"moistureLevel": 68, "warning": "بررسی شود"}, ' + '"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], ' + '"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, ' + '{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}' + ) + + def build_fertilization_optimizer_result(self): + return { + "engine": "crop_simulation_heuristic", + "context_text": "optimizer fertilization context", + "recommended_strategy": { + "code": "balanced", + "label": "تغذیه متعادل", + "score": 84.0, + "expected_yield_index": 88.0, + "fertilizer_type": "20-20-20", + "amount_kg_per_ha": 65.0, + "application_method": "کودآبیاری", + "timing": "صبح زود", + "validity_period": "معتبر برای 7 روز آینده", + "reasoning": ["کسری عناصر با این سناریو بهتر پوشش داده می شود."], + }, + "alternatives": [ + { + "code": "maintenance", + "label": "تغذیه نگهدارنده", + "score": 72.0, + "expected_yield_index": 78.0, + "fertilizer_type": "20-20-20", + "amount_kg_per_ha": 45.0, + "application_method": "کودآبیاری", + "timing": "صبح زود", + "reasoning": ["برای نگهداری تعادل تغذیه ای گزینه سبک تری است."], + } + ], + } + + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation._get_optimizer") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_uses_farm_relations_when_request_omits_names( + self, + mock_get_chat_client, + mock_get_optimizer, + mock_build_rag_context, + mock_build_plant_text, + mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + mock_get_optimizer.return_value.optimize_irrigation.return_value = ( + self.build_irrigation_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + ) + + self.assertEqual(result["plan"]["frequencyPerWeek"], 3) + self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح") + mock_build_rag_context.assert_called_once() + mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی") + mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای") + self.assertEqual( + result["selected_irrigation_method"]["name"], + "آبیاری قطره‌ای", + ) + self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") + self.assertEqual(result["timeline"][0]["title"], "بازبینی") + self.assertEqual(result["sections"][1]["type"], "tip") + self.assertEqual(result["water_balance"]["active_kc"], 0.9) + + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation._get_optimizer") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_reads_from_canonical_farm_data_assignments( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + _mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + assign_farm_plants_from_backend_ids(self.farm, [self.onion.backend_plant_id, self.plant.backend_plant_id]) + mock_get_optimizer.return_value.optimize_irrigation.return_value = self.build_irrigation_optimizer_result() + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + ) + + self.assertEqual(result["selected_plant"]["name"], "پیاز") + mock_build_plant_text.assert_called_once_with("پیاز", "میوه‌دهی") + + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation._get_optimizer") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_persists_selected_method_on_farm( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + _mock_build_plant_text, + mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + sprinkler = IrrigationMethod.objects.create(name="بارانی") + self.farm.irrigation_method = None + self.farm.save(update_fields=["irrigation_method", "updated_at"]) + + mock_get_optimizer.return_value.optimize_irrigation.return_value = ( + self.build_irrigation_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + irrigation_method_name="بارانی", + ) + + self.farm.refresh_from_db() + self.assertEqual(self.farm.irrigation_method_id, sprinkler.id) + self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id) + mock_build_irrigation_method_text.assert_called_once_with("بارانی") + self.assertEqual(result["plan"]["warning"], "بررسی شود") + + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation._get_optimizer") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + _mock_build_plant_text, + _mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + mock_get_optimizer.return_value.optimize_irrigation.return_value = ( + self.build_irrigation_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="not-json"))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + ) + + self.assertEqual(result["plan"]["frequencyPerWeek"], 3) + self.assertEqual(result["timeline"][0]["step_number"], 1) + self.assertEqual(result["sections"][0]["type"], "warning") + self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") + + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_uses_farm_plant_when_request_omits_name( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + ): + mock_get_optimizer.return_value.optimize_fertilization.return_value = ( + self.build_fertilization_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content='{"status": "success", "data": {"primary_recommendation": {"display_title": "کود کامل 20-20-20", "reasoning": "توضیح", "summary": "مصرف انجام شود"}, "nutrient_analysis": {"macro": [{"key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent", "description": "..."}, {"key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent", "description": "..."}, {"key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent", "description": "..."}], "micro": []}, "application_guide": {"safety_warning": "از اختلاط نامناسب خودداری شود.", "steps": [{"step_number": 1, "title": "آماده سازی", "description": "مورد 1"}]}, "alternative_recommendations": []}}'))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="رویشی", + ) + + self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) + mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "رویشی") + self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic") + self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.") + + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_resolves_requested_plant_from_catalog( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + ): + mock_get_optimizer.return_value.optimize_fertilization.return_value = ( + self.build_fertilization_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="not-json"))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + farm_uuid=str(self.farm_uuid), + plant_name="پیاز", + growth_stage="گلدهی", + ) + + optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs + self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز") + self.assertEqual(optimizer_call["growth_stage"], "flowering") + mock_build_plant_text.assert_called_once_with("پیاز", "flowering") + self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_uses_canonical_assignment_lookup_for_requested_catalog_plant( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + mock_build_plant_text, + ): + assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id, self.onion.backend_plant_id]) + mock_get_optimizer.return_value.optimize_fertilization.return_value = self.build_fertilization_optimizer_result() + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="not-json"))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + farm_uuid=str(self.farm_uuid), + plant_name="پیاز", + growth_stage="گلدهی", + ) + + optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs + self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز") + mock_build_plant_text.assert_called_once_with("پیاز", "flowering") + self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization._get_optimizer") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload( + self, + mock_get_chat_client, + mock_get_optimizer, + _mock_build_rag_context, + _mock_build_plant_text, + ): + mock_get_optimizer.return_value.optimize_fertilization.return_value = ( + self.build_fertilization_optimizer_result() + ) + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content="not-json"))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + farm_uuid=str(self.farm_uuid), + growth_stage="رویشی", + ) + + self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") + self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0) diff --git a/Modules/Ai/rag/urls.py b/Modules/Ai/rag/urls.py new file mode 100644 index 0000000..14b8450 --- /dev/null +++ b/Modules/Ai/rag/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import ( + ChatView, +) + +urlpatterns = [ + path("chat/", ChatView.as_view()), +] diff --git a/Modules/Ai/rag/user_data.py b/Modules/Ai/rag/user_data.py new file mode 100644 index 0000000..3dab7d4 --- /dev/null +++ b/Modules/Ai/rag/user_data.py @@ -0,0 +1,205 @@ +""" +ساخت دیتای خاک و هواشناسی کاربر از farm_data، location_data و weather — Schema-agnostic +هر سنسور = یک کاربر. شناسایی با farm_uuid. + +مدل‌های Django داخل توابع import می‌شوند تا از AppRegistryNotReady جلوگیری شود. +""" +from datetime import date + +from django.db.models import Model + + +EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at", "fetched_at"} + + +def _model_to_data_fields(instance: Model, exclude: set[str] | None = None) -> dict: + """ + استخراج فیلدهای داده از یک instance با استفاده از introspection. + تغییرات بعدی در مدل باعث شکستن نمی‌شود. + """ + exclude = exclude or set() + out: dict = {} + for f in instance._meta.get_fields(): + if f.many_to_many or f.one_to_many or f.one_to_one and f.auto_created: + continue + if f.name in exclude or f.name in EXCLUDE_FIELD_NAMES: + continue + if hasattr(f, "related_model") and f.related_model: + continue # FK + try: + val = getattr(instance, f.name, None) + if val is not None: + out[f.name] = val + except Exception: + pass + return out + + +def build_user_soil_text(sensor_uuid: str) -> str | None: + """ + ساخت متن قابل embed برای یک سنسور (کاربر). + از SensorData → SoilLocation → latest remote sensing snapshots خوانده می‌شود. + + Returns: + متن متنی قابل چانک، یا None اگر سنسور یافت نشد. + """ + from farm_data.models import SensorData + from location_data.satellite_snapshot import build_location_block_satellite_snapshots + + try: + sensor = SensorData.objects.select_related("center_location").get( + farm_uuid=sensor_uuid + ) + except SensorData.DoesNotExist: + return None + + parts: list[str] = [] + + # شناسه سنسور + parts.append(f"سنسور: {sensor.farm_uuid}") + + # موقعیت مزرعه + loc = sensor.center_location + parts.append( + f"موقعیت مزرعه: عرض {loc.latitude}، طول {loc.longitude}" + ) + + # خوانش‌های سنسور (schema-agnostic) + sensor_fields = _model_to_data_fields( + sensor, exclude={"farm_uuid", "center_location_id", "center_location", "location"} + ) + if sensor_fields: + sensor_lines = [f" {k}: {v}" for k, v in sorted(sensor_fields.items())] + parts.append("خوانش‌های سنسور:\n" + "\n".join(sensor_lines)) + + snapshots = build_location_block_satellite_snapshots(loc) + if snapshots: + snapshot_lines = [] + for snapshot in snapshots: + metrics = snapshot.get("resolved_metrics") or {} + if not metrics: + continue + lines = [f" {k}: {v}" for k, v in sorted(metrics.items())] + snapshot_lines.append( + f" بلوک {snapshot.get('block_code') or 'farm'}:\n" + "\n".join(lines) + ) + if snapshot_lines: + parts.append("داده‌های ماهواره‌ای:\n" + "\n".join(snapshot_lines)) + + return "\n\n".join(parts) if len(parts) > 1 else None + + +def get_all_sensor_uuids() -> list[str]: + """لیست همه farm_uuid های موجود.""" + from farm_data.models import SensorData + + return [ + str(u) for u in + SensorData.objects.values_list("farm_uuid", flat=True).distinct() + ] + + +def build_user_weather_text(sensor_uuid: str) -> str | None: + """ + ساخت متن هواشناسی قابل embed برای یک سنسور (کاربر). + پیش‌بینی ۷ روز آینده از WeatherForecast خوانده می‌شود. + + Returns: + متن فارسی ساختاریافته، یا None اگر داده‌ای نباشد. + """ + from farm_data.models import SensorData + from weather.models import WeatherForecast + + try: + sensor = SensorData.objects.select_related("center_location").get( + farm_uuid=sensor_uuid + ) + except SensorData.DoesNotExist: + return None + + loc = sensor.center_location + forecasts = ( + WeatherForecast.objects.filter( + location=loc, + forecast_date__gte=date.today(), + ) + .order_by("forecast_date")[:7] + ) + if not forecasts: + return None + + parts: list[str] = [] + parts.append(f"پیش‌بینی هواشناسی سنسور {sensor_uuid} (موقعیت: {loc.latitude}, {loc.longitude})") + + for fc in forecasts: + fc_data = _model_to_data_fields( + fc, exclude={"location", "location_id", "forecast_date"} + ) + lines = [f" {k}: {v}" for k, v in sorted(fc_data.items())] + day_text = f" تاریخ {fc.forecast_date}:\n" + "\n".join(lines) + parts.append(day_text) + + return "\n\n".join(parts) if len(parts) > 1 else None + + +def load_user_sources() -> list[tuple[str, str]]: + """ + بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی). + Returns: [(source_id, content), ...] + source_id = user:{uuid} یا weather:{uuid} + """ + uuids = get_all_sensor_uuids() + sources: list[tuple[str, str]] = [] + for uid in uuids: + text = build_user_soil_text(str(uid)) + if text and text.strip(): + sources.append((f"user:{uid}", text)) + weather_text = build_user_weather_text(str(uid)) + if weather_text and weather_text.strip(): + sources.append((f"weather:{uid}", weather_text)) + return sources + + +def build_plant_text(plant_name: str, growth_stage: str) -> str | None: + """ + ساخت متن اطلاعات گیاه از snapshotهای `farm_data` برای استفاده در context LLM. + """ + from farm_data.models import PlantCatalogSnapshot + from farm_data.services import build_plant_text_from_snapshot + + plant = PlantCatalogSnapshot.objects.filter(name=plant_name).first() + if not plant: + return None + + return build_plant_text_from_snapshot(plant, growth_stage) + + +def build_irrigation_method_text(method_name: str) -> str | None: + """ + ساخت متن مشخصات روش آبیاری از جدول IrrigationMethod برای استفاده در context LLM. + """ + from irrigation.models import IrrigationMethod + + method = IrrigationMethod.objects.filter(name=method_name).first() + if not method: + return None + + lines = [f"روش آبیاری: {method.name}"] + if method.category: + lines.append(f"دسته‌بندی: {method.category}") + if method.description: + lines.append(f"توضیحات: {method.description}") + if method.water_efficiency_percent is not None: + lines.append(f"راندمان مصرف آب: {method.water_efficiency_percent}%") + if method.water_pressure_required: + lines.append(f"فشار مورد نیاز: {method.water_pressure_required}") + if method.flow_rate: + lines.append(f"دبی جریان: {method.flow_rate}") + if method.coverage_area: + lines.append(f"مساحت پوشش: {method.coverage_area}") + if method.soil_type: + lines.append(f"نوع خاک مناسب: {method.soil_type}") + if method.climate_suitability: + lines.append(f"اقلیم مناسب: {method.climate_suitability}") + + return "\n".join(lines) diff --git a/Modules/Ai/rag/vector_store.py b/Modules/Ai/rag/vector_store.py new file mode 100644 index 0000000..6fa261b --- /dev/null +++ b/Modules/Ai/rag/vector_store.py @@ -0,0 +1,172 @@ +""" +Qdrant Vector Store — ذخیره و جستجوی وکتورها +""" +from qdrant_client import QdrantClient +from qdrant_client.http import models as qmodels + +from .client import get_qdrant_client +from .config import load_rag_config, RAGConfig + + +class QdrantVectorStore: + """ + ذخیره و جستجوی documents در Qdrant. + """ + + def __init__(self, config: RAGConfig | None = None): + self.config = config or load_rag_config() + self.qdrant = self.config.qdrant + self._client: QdrantClient | None = None + + @property + def client(self) -> QdrantClient: + if self._client is None: + self._client = get_qdrant_client(self.qdrant) + return self._client + + def ensure_collection(self, recreate: bool = False) -> None: + """ + اطمینان از وجود collection با نام و اندازه مناسب. + """ + name = self.qdrant.collection_name + size = self.qdrant.vector_size + + try: + self.client.get_collection(name) + if recreate: + self.client.delete_collection(name) + self.client.create_collection( + collection_name=name, + vectors_config=qmodels.VectorParams( + size=size, + distance=qmodels.Distance.COSINE, + ), + ) + except Exception: + self.client.create_collection( + collection_name=name, + vectors_config=qmodels.VectorParams( + size=size, + distance=qmodels.Distance.COSINE, + ), + ) + + def add_documents( + self, + ids: list[str], + embeddings: list[list[float]], + documents: list[str], + metadatas: list[dict] | None = None, + ) -> int: + """ + افزودن documents به collection. + metadata فقط str, int, float, bool پشتیبانی می‌شود. + """ + self.ensure_collection() + metas = metadatas or [{}] * len(ids) + + def _serialize(m: dict) -> dict: + out = {} + for k, v in m.items(): + if v is None: + continue + if isinstance(v, (str, int, float, bool)): + out[k] = v + else: + out[k] = str(v) + return out + + payloads = [ + {"text": doc, "doc_id": sid, **_serialize(m)} + for doc, m, sid in zip(documents, metas, ids) + ] + + self.client.upsert( + collection_name=self.qdrant.collection_name, + points=[ + qmodels.PointStruct(id=pid, vector=emb, payload=pl) + for pid, emb, pl in zip(ids, embeddings, payloads) + ], + ) + return len(ids) + + def search( + self, + query_vector: list[float], + limit: int = 5, + score_threshold: float | None = None, + sensor_uuid: str | None = None, + kb_name: str | None = None, + sensor_uuids: list[str] | None = None, + kb_names: list[str] | None = None, + ) -> list[dict]: + """ + جستجوی شباهت بر اساس query vector. + روی نسخه‌های جدید از query_points و روی نسخه‌های قدیمی‌تر از search استفاده می‌کند. + sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده می‌شود. + kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization). + اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده می‌شود. + """ + must_conditions = [] + + sensor_values = [value for value in (sensor_uuids or ([sensor_uuid] if sensor_uuid else [])) if value] + if sensor_values: + must_conditions.append( + qmodels.Filter( + should=[ + qmodels.FieldCondition( + key="sensor_uuid", + match=qmodels.MatchValue(value=value), + ) + for value in sensor_values + ] + ) + ) + + kb_values = [value for value in (kb_names or ([kb_name] if kb_name else [])) if value] + if kb_values: + must_conditions.append( + qmodels.Filter( + should=[ + qmodels.FieldCondition( + key="kb_name", + match=qmodels.MatchValue(value=value), + ) + for value in kb_values + ] + ) + ) + + query_filter = None + if must_conditions: + query_filter = qmodels.Filter(must=must_conditions) + + if hasattr(self.client, "query_points"): + response = self.client.query_points( + collection_name=self.qdrant.collection_name, + query=query_vector, + limit=limit, + score_threshold=score_threshold, + query_filter=query_filter, + ) + points = getattr(response, "points", []) or [] + else: + points = self.client.search( + collection_name=self.qdrant.collection_name, + query_vector=query_vector, + limit=limit, + score_threshold=score_threshold, + query_filter=query_filter, + ) + + return [ + { + "id": str(r.id), + "score": float(r.score) if r.score is not None else 0.0, + "text": (r.payload or {}).get("text", ""), + "metadata": { + k: v for k, v in (r.payload or {}).items() if k != "text" + }, + } + for r in points + ] diff --git a/Modules/Ai/rag/views.py b/Modules/Ai/rag/views.py new file mode 100644 index 0000000..79fb9c4 --- /dev/null +++ b/Modules/Ai/rag/views.py @@ -0,0 +1,205 @@ +""" +ویوهای RAG — چت با استریم +""" +import json +import logging + +from django.http import StreamingHttpResponse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import status +from rest_framework import serializers as drf_serializers +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import ( + build_envelope_serializer, + build_message_response_serializer, + build_response, +) +from .chat import chat_rag_stream, encode_uploaded_image + + +logger = logging.getLogger(__name__) + + +RagChatErrorResponseSerializer = build_message_response_serializer( + "RagChatErrorResponseSerializer" +) +RagValidationErrorResponseSerializer = build_envelope_serializer( + "RagValidationErrorResponseSerializer", + data_required=False, + allow_null=True, +) + + +class ChatView(APIView): + parser_classes = [JSONParser, MultiPartParser, FormParser] + + def _parse_history(self, raw_history): + if raw_history in (None, "", []): + return [] + if isinstance(raw_history, list): + return raw_history + if isinstance(raw_history, str): + try: + parsed = json.loads(raw_history) + except (json.JSONDecodeError, ValueError): + raise ValueError("history باید JSON معتبر باشد.") + if not isinstance(parsed, list): + raise ValueError("history باید آرایه باشد.") + return parsed + raise ValueError("history فرمت پشتیبانی شده ندارد.") + + def _collect_uploaded_images(self, request: Request): + images = [] + for uploaded in request.FILES.getlist("images"): + images.append(encode_uploaded_image(uploaded)) + single_image = request.FILES.get("image") + if single_image is not None: + images.append(encode_uploaded_image(single_image)) + image_urls = request.data.get("image_urls") + if isinstance(image_urls, str) and image_urls.strip(): + try: + parsed_urls = json.loads(image_urls) + except (json.JSONDecodeError, ValueError): + parsed_urls = [image_urls] + image_urls = parsed_urls + if isinstance(image_urls, list): + for item in image_urls: + if isinstance(item, str) and item.strip(): + images.append({"url": item.strip(), "detail": "auto"}) + elif isinstance(item, dict) and isinstance(item.get("url"), str): + image_payload = {"url": item["url"].strip(), "detail": item.get("detail", "auto")} + images.append(image_payload) + return images + + @extend_schema( + tags=["RAG Chat"], + summary="چت RAG با استریم", + description="پیام کاربر را دریافت و پاسخ را به صورت استریم برمی‌گرداند.", + request=inline_serializer( + name="ChatRequest", + fields={ + "query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"), + "message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"), + "farm_uuid": drf_serializers.CharField(help_text="شناسه مزرعه"), + "history": drf_serializers.JSONField(required=False, help_text="آرایه پیام های قبلی با role=user/assistant"), + "image_urls": drf_serializers.JSONField(required=False, help_text="آرایه URL تصاویر برای پیام فعلی"), + "image": drf_serializers.FileField(required=False, help_text="یک تصویر برای پیام فعلی"), + "images": drf_serializers.ListField( + child=drf_serializers.FileField(), + required=False, + help_text="چند تصویر برای پیام فعلی", + ), + }, + ), + responses={ + 200: OpenApiResponse( + response=OpenApiTypes.STR, + description="پاسخ استریم متنی (text/plain)", + ), + 400: build_response( + RagChatErrorResponseSerializer, + "پارامترهای ورودی نامعتبر هستند.", + ), + 404: build_response( + RagChatErrorResponseSerializer, + "مزرعه پیدا نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "query": "وضعیت مزرعه من چطور است؟", + "history": [ + {"role": "user", "content": "رطوبت خاک من پایین بود؟"}, + {"role": "assistant", "content": "بله، رطوبت خاک کمتر از محدوده مطلوب بود."}, + ], + "image_urls": ["https://example.com/farm-photo.jpg"], + }, + request_only=True, + ), + ], + ) + def post(self, request: Request): + from farm_data.services import get_farm_details + from .config import load_rag_config + + data = request.data if request.method == "POST" else request.query_params + message = data.get("query", data.get("message")) + farm_uuid = data.get("farm_uuid") + raw_history = data.get("history") + try: + images = self._collect_uploaded_images(request) + except ValueError as exc: + return Response( + {"code": 400, "msg": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + if message is None and images: + message = "لطفا تصویر ارسالی را در کنار اطلاعات مزرعه بررسی کن." + if not message or not isinstance(message, str): + return Response( + {"code": 400, "msg": "پارامتر query الزامی است، مگر اینکه تصویر ارسال شده باشد."}, + status=status.HTTP_400_BAD_REQUEST, + ) + message = str(message).strip() + if not message: + return Response( + {"code": 400, "msg": "پیام نباید خالی باشد."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not farm_uuid or not isinstance(farm_uuid, str): + return Response( + {"code": 400, "msg": "پارامتر farm_uuid الزامی است."}, + status=status.HTTP_400_BAD_REQUEST, + ) + farm_uuid = str(farm_uuid).strip() + if not farm_uuid: + return Response( + {"code": 400, "msg": "farm_uuid نباید خالی باشد."}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + history = self._parse_history(raw_history) + except ValueError as exc: + return Response( + {"code": 400, "msg": str(exc)}, + status=status.HTTP_400_BAD_REQUEST, + ) + cfg = load_rag_config() + farm_details = get_farm_details(farm_uuid) + if farm_details is None: + return Response( + {"code": 404, "msg": "farm پیدا نشد."}, + status=status.HTTP_404_NOT_FOUND, + ) + + def generate(): + try: + for chunk in chat_rag_stream( + message, + farm_uuid=farm_uuid, + config=cfg, + farm_details=farm_details, + history=history, + images=images, + ): + yield chunk + except Exception as e: + yield f"\n[خطا: {e}]" + + return StreamingHttpResponse( + generate(), + content_type="text/plain; charset=utf-8", + ) diff --git a/Modules/Ai/requirements.txt b/Modules/Ai/requirements.txt new file mode 100644 index 0000000..ecca7c7 --- /dev/null +++ b/Modules/Ai/requirements.txt @@ -0,0 +1,42 @@ +# === Core Django === +Django>=5.0,<5.2 +djangorestframework>=3.14,<3.16 +djangorestframework-simplejwt>=5.3,<5.4 +django-cors-headers>=4.3,<4.5 + +# === Database === +mysqlclient>=2.2,<2.3 + +# === Server === +gunicorn>=22,<23 +whitenoise>=6.7,<6.8 + +# === API Docs === +drf-spectacular>=0.27,<0.28 +drf-spectacular-sidecar>=2024.5,<2025.0 + +# === Config === +python-dotenv>=1.0,<1.1 + +# === Task Queue === +celery[redis]>=5.3,<5.4 +redis>=5.0,<5.1 + +# === HTTP & AI === +requests>=2.31,<2.32 +httpx>=0.27,<0.28 +openai>=1.0,<1.40 +openeo>=0.29,<0.40 + +# === NumPy (pinned for Python 3.10 compatibility) === +numpy>=1.23,<1.27 +scikit-learn>=1.3,<1.6 +matplotlib>=3.7,<3.9 +Pillow>=10.0,<11.0 +pcse + +# === Vector Databases === +qdrant-client>=1.7,<1.9 + +# === Utils === +pyyaml>=6.0,<7 diff --git a/Modules/Ai/scripts/fix_farm_data_tables.sh b/Modules/Ai/scripts/fix_farm_data_tables.sh new file mode 100644 index 0000000..ffcf84e --- /dev/null +++ b/Modules/Ai/scripts/fix_farm_data_tables.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Fix: جداول farm_data وجود ندارند اما migrationهای legacy با label قدیمی ثبت شده‌اند. +# اجرا: docker compose run --rm web sh /app/scripts/fix_farm_data_tables.sh +set -e +cd /app +echo "Resetting legacy sensor_data migrations (fake unapply - tables may not exist)..." +python manage.py migrate sensor_data zero --noinput --fake +echo "Re-applying legacy sensor_data migrations (--fake-initial if tables already exist)..." +python manage.py migrate sensor_data --noinput --fake-initial +echo "Done. Running seed_sensor_parameters..." +python manage.py seed_sensor_parameters +echo "All done." diff --git a/Modules/Ai/scripts/generate_mock_data.py b/Modules/Ai/scripts/generate_mock_data.py new file mode 100644 index 0000000..d868714 --- /dev/null +++ b/Modules/Ai/scripts/generate_mock_data.py @@ -0,0 +1,798 @@ +import json +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parents[1] +MOCK_DIR = BASE_DIR / "json" / "mock_data" + + +def queue_response(message, task_id, status_url, **extra): + data = { + "task_id": task_id, + "status_url": status_url, + } + data.update(extra) + return { + "code": 202, + "msg": message, + "data": data, + } + + +def ok_response(data, message="success", code=200): + return { + "code": code, + "msg": message, + "data": data, + } + + +def error_response(code, message, data=None): + return { + "code": code, + "msg": message, + "data": data, + } + + +def task_pending(task_id): + return ok_response( + { + "task_id": task_id, + "status": "PENDING", + "message": "تسک در صف یا یافت نشد.", + } + ) + + +def task_progress(task_id, progress): + return ok_response( + { + "task_id": task_id, + "status": "PROGRESS", + "progress": progress, + } + ) + + +def task_success(task_id, result): + return ok_response( + { + "task_id": task_id, + "status": "SUCCESS", + "result": result, + } + ) + + +def task_failure(task_id, error): + return ok_response( + { + "task_id": task_id, + "status": "FAILURE", + "error": error, + } + ) + + +PLANT = { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z", +} + +IRRIGATION_METHOD = { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z", +} + +SOIL_DEPTHS = [ + { + "depth_label": "0-5cm", + "bdod": 1.31, + "cec": 18.4, + "cfvo": 2.0, + "clay": 24.0, + "nitrogen": 0.18, + "ocd": 32.0, + "ocs": 4.1, + "phh2o": 7.2, + "sand": 34.0, + "silt": 42.0, + "soc": 1.6, + "wv0010": 0.31, + "wv0033": 0.22, + "wv1500": 0.11, + }, + { + "depth_label": "5-15cm", + "bdod": 1.35, + "cec": 17.2, + "cfvo": 2.3, + "clay": 26.0, + "nitrogen": 0.16, + "ocd": 28.0, + "ocs": 3.7, + "phh2o": 7.1, + "sand": 36.0, + "silt": 38.0, + "soc": 1.4, + "wv0010": 0.29, + "wv0033": 0.2, + "wv1500": 0.1, + }, + { + "depth_label": "15-30cm", + "bdod": 1.39, + "cec": 15.8, + "cfvo": 2.8, + "clay": 28.0, + "nitrogen": 0.13, + "ocd": 22.0, + "ocs": 3.2, + "phh2o": 7.0, + "sand": 38.0, + "silt": 34.0, + "soc": 1.1, + "wv0010": 0.26, + "wv0033": 0.18, + "wv1500": 0.09, + }, +] + +SOIL_LOCATION = { + "source": "database", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "depths": SOIL_DEPTHS, +} + +SOIL_TASK_DATA = { + "source": "task", + "task_id": "soil-task-123", + "lon": 51.389, + "lat": 35.6892, + "status_url": "/api/soil-data/tasks/soil-task-123/status/", +} + +SENSOR_DATA = { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "center_location_id": 12, + "weather_forecast_id": 21, + "sensor_payload": { + "sensor-7-1": { + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + } + }, + "plant_ids": [1], + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z", +} + +SENSOR_PARAMETER = { + "id": 3, + "sensor_key": "sensor-7-1", + "code": "soil_moisture", + "name_fa": "رطوبت خاک", + "unit": "%", + "data_type": "float", + "metadata": {"min": 0, "max": 100}, + "created_at": "2025-03-24T10:00:00Z", + "action": "added", +} + +IRRIGATION_RECOMMENDATION_RESULT = { + "plan": { + "frequencyPerWeek": 3, + "durationMinutes": 42, + "bestTimeOfDay": "صبح زود", + "moistureLevel": 68, + "warning": "در صورت بارش موثر، نوبت سوم این هفته را حذف کنید.", + }, + "raw_response": "{\"plan\":{\"frequencyPerWeek\":3,\"durationMinutes\":42}}", + "water_balance": { + "daily": [ + { + "forecast_date": "2025-03-25", + "et0_mm": 4.7, + "etc_mm": 5.6, + "effective_rainfall_mm": 0.0, + "gross_irrigation_mm": 6.2, + "irrigation_timing": "06:00-08:00", + } + ], + "crop_profile": { + "kc_initial": 0.6, + "kc_mid": 1.15, + "kc_end": 0.8, + }, + "active_kc": 1.15, + }, + "status": "completed", +} + +FERTILIZATION_RECOMMENDATION_RESULT = { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "کودآبیاری در دو نوبت", + "applicationInterval": "هر ۱۰ روز", + "reasoning": "نیتروژن و پتاسیم خاک در محدوده متوسط است و گیاه در فاز رویشی نیاز تغذیه‌ای بالاتری دارد.", + }, + "raw_response": "{\"plan\":{\"npkRatio\":\"20-20-20\"}}", + "status": "completed", +} + +DASHBOARD_RESULT = { + "sensor_id": "550e8400-e29b-41d4-a716-446655440000", + "all_cards": { + "farmOverviewKpis": { + "healthScore": 82, + "activeAlerts": 2, + "waterNeedMm": 18.4, + }, + }, +} + +TASK_PROGRESS = {"current": 1, "total": 3, "message": "در حال پردازش..."} + +RAG_STREAM_SUCCESS = { + "content_type": "text/plain; charset=utf-8", + "body": "سلام، برای بازیابی رطوبت خاک بهتر است آبیاری صبح‌گاهی را تنظیم کنید.", +} + +index = [] + + +def register(path, method, api_path, status_code, description, payload): + full_path = MOCK_DIR / path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + index.append( + { + "method": method, + "path": api_path, + "status_code": status_code, + "description": description, + "file": str(Path("json/mock_data") / path), + } + ) + + +def main(): + register( + "dashboard-data/generate/post_202.json", + "POST", + "/api/dashboard-data/generate/", + 202, + "Dashboard data task queued", + queue_response( + "dashboard task queued", + "dashboard-task-123", + "/api/dashboard-data/dashboard-task-123/status/", + ), + ) + register( + "dashboard-data/generate/post_400.json", + "POST", + "/api/dashboard-data/generate/", + 400, + "Missing sensor_id", + error_response(400, "پارامتر sensor_id الزامی است.", None), + ) + register( + "dashboard-data/status/get_200_pending.json", + "GET", + "/api/dashboard-data/{task_id}/status/", + 200, + "Pending dashboard task", + task_pending("dashboard-task-123"), + ) + register( + "dashboard-data/status/get_200_progress.json", + "GET", + "/api/dashboard-data/{task_id}/status/", + 200, + "Dashboard task in progress", + task_progress( + "dashboard-task-123", + {"current": 5, "total": 15, "card": "sensorComparisonChart", "message": "processing sensorComparisonChart"}, + ), + ) + register( + "dashboard-data/status/get_200_success.json", + "GET", + "/api/dashboard-data/{task_id}/status/", + 200, + "Successful dashboard task", + task_success("dashboard-task-123", DASHBOARD_RESULT), + ) + register( + "dashboard-data/status/get_200_failure.json", + "GET", + "/api/dashboard-data/{task_id}/status/", + 200, + "Failed dashboard task", + task_failure("dashboard-task-123", "خطا در ساخت کارت‌های داشبورد."), + ) + + register( + "fertilization/recommend/post_202.json", + "POST", + "/api/fertilization/recommend/", + 202, + "Fertilization task queued", + queue_response( + "تسک توصیه کودهی در صف قرار گرفت.", + "fert-task-123", + "/api/fertilization/recommend/fert-task-123/status/", + ), + ) + register( + "fertilization/recommend/post_400.json", + "POST", + "/api/fertilization/recommend/", + 400, + "Validation error", + error_response(400, "داده نامعتبر.", {"sensor_uuid": ["This field is required."]}), + ) + for name, payload in { + "pending": task_pending("fert-task-123"), + "progress": task_progress("fert-task-123", {"message": "در حال پردازش توصیه کودهی..."}), + "success": task_success("fert-task-123", FERTILIZATION_RECOMMENDATION_RESULT), + "failure": task_failure("fert-task-123", "خطا در دریافت توصیه کودهی."), + }.items(): + register( + f"fertilization/status/get_200_{name}.json", + "GET", + "/api/fertilization/recommend/{task_id}/status/", + 200, + f"Fertilization status {name}", + payload, + ) + + register( + "irrigation/methods/get_200.json", + "GET", + "/api/irrigation/", + 200, + "List irrigation methods", + ok_response([IRRIGATION_METHOD]), + ) + register( + "irrigation/methods/post_201.json", + "POST", + "/api/irrigation/", + 201, + "Create irrigation method", + ok_response(IRRIGATION_METHOD, code=201), + ) + register( + "irrigation/methods/post_400.json", + "POST", + "/api/irrigation/", + 400, + "Irrigation create validation error", + error_response(400, "داده نامعتبر.", {"name": ["This field is required."]}), + ) + register( + "irrigation/recommend/post_202.json", + "POST", + "/api/irrigation/recommend/", + 202, + "Irrigation recommendation task queued", + queue_response( + "تسک توصیه آبیاری در صف قرار گرفت.", + "irr-task-123", + "/api/irrigation/recommend/irr-task-123/status/", + ), + ) + register( + "irrigation/recommend/post_400.json", + "POST", + "/api/irrigation/recommend/", + 400, + "Irrigation recommendation validation error", + error_response(400, "داده نامعتبر.", {"sensor_uuid": ["This field is required."]}), + ) + for name, payload in { + "pending": task_pending("irr-task-123"), + "progress": task_progress("irr-task-123", {"message": "در حال پردازش توصیه آبیاری..."}), + "success": task_success("irr-task-123", IRRIGATION_RECOMMENDATION_RESULT), + "failure": task_failure("irr-task-123", "خطا در دریافت توصیه آبیاری."), + }.items(): + register( + f"irrigation/recommend/status/get_200_{name}.json", + "GET", + "/api/irrigation/recommend/{task_id}/status/", + 200, + f"Irrigation recommendation status {name}", + payload, + ) + + for method, success_code in (("get", 200), ("put", 200), ("patch", 200)): + register( + f"irrigation/method-detail/{method}_{success_code}.json", + method.upper(), + "/api/irrigation/{pk}/", + success_code, + f"Irrigation method {method} success", + ok_response(IRRIGATION_METHOD, code=success_code), + ) + if method in {"put", "patch"}: + register( + f"irrigation/method-detail/{method}_400.json", + method.upper(), + "/api/irrigation/{pk}/", + 400, + f"Irrigation method {method} validation error", + error_response(400, "داده نامعتبر.", {"name": ["This field may not be blank."]}), + ) + register( + f"irrigation/method-detail/{method}_404.json", + method.upper(), + "/api/irrigation/{pk}/", + 404, + f"Irrigation method {method} not found", + error_response(404, "روش آبیاری یافت نشد.", None), + ) + register( + "irrigation/method-detail/delete_200.json", + "DELETE", + "/api/irrigation/{pk}/", + 200, + "Delete irrigation method", + error_response(200, "روش آبیاری با موفقیت حذف شد.", None), + ) + register( + "irrigation/method-detail/delete_404.json", + "DELETE", + "/api/irrigation/{pk}/", + 404, + "Delete irrigation method not found", + error_response(404, "روش آبیاری یافت نشد.", None), + ) + + register( + "soil-data/get_200_database.json", + "GET", + "/api/soil-data/", + 200, + "Soil data served from database", + ok_response(SOIL_LOCATION), + ) + register( + "soil-data/get_202_queued.json", + "GET", + "/api/soil-data/", + 202, + "Soil data fetch task queued", + { + "code": 202, + "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", + "data": SOIL_TASK_DATA, + }, + ) + register( + "soil-data/get_400.json", + "GET", + "/api/soil-data/", + 400, + "Soil data validation error", + error_response(400, "داده نامعتبر.", {"lat": ["This field is required."], "lon": ["This field is required."]}), + ) + register( + "soil-data/post_200_database.json", + "POST", + "/api/soil-data/", + 200, + "Soil data POST served from database", + ok_response(SOIL_LOCATION), + ) + register( + "soil-data/post_202_queued.json", + "POST", + "/api/soil-data/", + 202, + "Soil data POST task queued", + { + "code": 202, + "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", + "data": SOIL_TASK_DATA, + }, + ) + register( + "soil-data/post_400.json", + "POST", + "/api/soil-data/", + 400, + "Soil data POST validation error", + error_response(400, "داده نامعتبر.", {"lat": ["A valid number is required."]}), + ) + for name, payload in { + "pending": task_pending("soil-task-123"), + "progress": task_progress("soil-task-123", {"step": "fetch", "percent": 60}), + "success": task_success("soil-task-123", SOIL_LOCATION | {"source": "database"}), + "failure": task_failure("soil-task-123", "خطا در واکشی داده خاک."), + }.items(): + register( + f"soil-data/status/get_200_{name}.json", + "GET", + "/api/soil-data/tasks/{task_id}/status/", + 200, + f"Soil task status {name}", + payload, + ) + + register( + "plant/list-get_200.json", + "GET", + "/api/plants/", + 200, + "List plants", + ok_response([PLANT]), + ) + register( + "plant/create-post_201.json", + "POST", + "/api/plants/", + 201, + "Create plant", + ok_response(PLANT, code=201), + ) + register( + "plant/create-post_400.json", + "POST", + "/api/plants/", + 400, + "Plant create validation error", + error_response(400, "داده نامعتبر.", {"name": ["This field is required."]}), + ) + for method in ("get", "put", "patch"): + register( + f"plant/detail-{method}_200.json", + method.upper(), + "/api/plants/{pk}/", + 200, + f"Plant detail {method} success", + ok_response(PLANT), + ) + if method in {"put", "patch"}: + register( + f"plant/detail-{method}_400.json", + method.upper(), + "/api/plants/{pk}/", + 400, + f"Plant detail {method} validation error", + error_response(400, "داده نامعتبر.", {"name": ["This field may not be blank."]}), + ) + register( + f"plant/detail-{method}_404.json", + method.upper(), + "/api/plants/{pk}/", + 404, + f"Plant detail {method} not found", + error_response(404, "گیاه یافت نشد.", None), + ) + register( + "plant/detail-delete_200.json", + "DELETE", + "/api/plants/{pk}/", + 200, + "Delete plant success", + error_response(200, "گیاه با موفقیت حذف شد.", None), + ) + register( + "plant/detail-delete_404.json", + "DELETE", + "/api/plants/{pk}/", + 404, + "Delete plant not found", + error_response(404, "گیاه یافت نشد.", None), + ) + register( + "plant/fetch-info-post_200.json", + "POST", + "/api/plants/fetch-info/", + 200, + "Fetch plant info success", + ok_response(PLANT), + ) + register( + "plant/fetch-info-post_400.json", + "POST", + "/api/plants/fetch-info/", + 400, + "Fetch plant info missing name", + error_response(400, "نام گیاه الزامی است.", None), + ) + register( + "plant/fetch-info-post_503.json", + "POST", + "/api/plants/fetch-info/", + 503, + "Fetch plant info service unavailable", + error_response(503, "سرویس API هنوز پیاده‌سازی نشده است.", None), + ) + + register( + "rag/chat-post_200_stream.json", + "POST", + "/api/rag/chat/", + 200, + "RAG chat streaming response", + RAG_STREAM_SUCCESS, + ) + register( + "rag/chat-post_400_missing_query.json", + "POST", + "/api/rag/chat/", + 400, + "Missing query", + {"code": 400, "msg": "پارامتر query الزامی است."}, + ) + register( + "rag/chat-post_400_invalid_service.json", + "POST", + "/api/rag/chat/", + 400, + "Invalid service id", + {"code": 400, "msg": "service_id نامعتبر است: unknown_service"}, + ) + register( + "rag/chat-post_400_missing_user.json", + "POST", + "/api/rag/chat/", + 400, + "Missing user_id for service", + {"code": 400, "msg": "برای این service_id، پارامتر user_id الزامی است."}, + ) + + register( + "rag/irrigation/post_202.json", + "POST", + "/api/rag/recommend/irrigation/", + 202, + "RAG irrigation task queued", + queue_response( + "تسک توصیه آبیاری در صف قرار گرفت.", + "rag-irr-123", + "/api/rag/recommend/irrigation/rag-irr-123/status/", + ), + ) + register( + "rag/irrigation/post_400.json", + "POST", + "/api/rag/recommend/irrigation/", + 400, + "RAG irrigation validation error", + error_response(400, "پارامتر sensor_uuid الزامی است.", None), + ) + for name, payload in { + "pending": task_pending("rag-irr-123"), + "progress": task_progress("rag-irr-123", {"message": "در حال پردازش توصیه آبیاری..."}), + "success": task_success("rag-irr-123", IRRIGATION_RECOMMENDATION_RESULT), + "failure": task_failure("rag-irr-123", "خطا در دریافت توصیه آبیاری."), + }.items(): + register( + f"rag/irrigation/status/get_200_{name}.json", + "GET", + "/api/rag/recommend/irrigation/{task_id}/status/", + 200, + f"RAG irrigation status {name}", + payload, + ) + + register( + "rag/fertilization/post_202.json", + "POST", + "/api/rag/recommend/fertilization/", + 202, + "RAG fertilization task queued", + queue_response( + "تسک توصیه کودهی در صف قرار گرفت.", + "rag-fert-123", + "/api/rag/recommend/fertilization/rag-fert-123/status/", + ), + ) + register( + "rag/fertilization/post_400.json", + "POST", + "/api/rag/recommend/fertilization/", + 400, + "RAG fertilization validation error", + error_response(400, "پارامتر sensor_uuid الزامی است.", None), + ) + for name, payload in { + "pending": task_pending("rag-fert-123"), + "progress": task_progress("rag-fert-123", {"message": "در حال پردازش توصیه کودهی..."}), + "success": task_success("rag-fert-123", FERTILIZATION_RECOMMENDATION_RESULT), + "failure": task_failure("rag-fert-123", "خطا در دریافت توصیه کودهی."), + }.items(): + register( + f"rag/fertilization/status/get_200_{name}.json", + "GET", + "/api/rag/recommend/fertilization/{task_id}/status/", + 200, + f"RAG fertilization status {name}", + payload, + ) + + register( + "farm-data/upsert-post_201.json", + "POST", + "/api/farm-data/", + 201, + "Farm data created", + ok_response(SENSOR_DATA, code=201), + ) + register( + "farm-data/upsert-post_400.json", + "POST", + "/api/farm-data/", + 400, + "Farm data validation error", + error_response(400, "داده نامعتبر.", {"farm_uuid": ["This field is required."]}), + ) + register( + "farm-data/upsert-post_404.json", + "POST", + "/api/farm-data/", + 400, + "Farm data invalid boundary", + error_response(400, "داده نامعتبر.", {"farm_boundary": ["farm_boundary باید حداقل 3 گوشه معتبر داشته باشد."]}), + ) + register( + "farm-data/parameters-post_201.json", + "POST", + "/api/farm-data/parameters/", + 201, + "Create sensor parameter", + ok_response(SENSOR_PARAMETER, code=201), + ) + register( + "farm-data/parameters-post_400.json", + "POST", + "/api/farm-data/parameters/", + 400, + "Sensor parameter validation error", + error_response(400, "داده نامعتبر.", {"code": ["This field is required."]}), + ) + + (MOCK_DIR / "index.json").write_text( + json.dumps(index, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/Modules/Ai/soile/__init__.py b/Modules/Ai/soile/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/soile/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/soile/anomaly_detection.py b/Modules/Ai/soile/anomaly_detection.py new file mode 100644 index 0000000..eac508a --- /dev/null +++ b/Modules/Ai/soile/anomaly_detection.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from math import sqrt +from statistics import mean +from typing import Any + + +METRIC_CONFIG = { + "soil_moisture": { + "label": "رطوبت خاک", + "unit": "%", + "source": "history", + "current_field": "soil_moisture", + }, + "soil_temperature": { + "label": "دمای خاک", + "unit": "°C", + "source": "history", + "current_field": "soil_temperature", + }, + "humidity": { + "label": "رطوبت هوا", + "unit": "%", + "source": "forecast", + "forecast_field": "humidity_mean", + }, + "soil_ph": { + "label": "pH خاک", + "unit": "pH", + "source": "history", + "current_field": "soil_ph", + }, + "electrical_conductivity": { + "label": "هدایت الکتریکی", + "unit": "dS/m", + "source": "history", + "current_field": "electrical_conductivity", + }, +} + +METHOD_PRIORITY = {"IQR": 2, "Z_SCORE": 1} + + +def _percentile(sorted_values: list[float], percentile: float) -> float: + if not sorted_values: + return 0.0 + if len(sorted_values) == 1: + return sorted_values[0] + index = (len(sorted_values) - 1) * percentile + lower = int(index) + upper = min(lower + 1, len(sorted_values) - 1) + fraction = index - lower + return sorted_values[lower] + ((sorted_values[upper] - sorted_values[lower]) * fraction) + + +def _population_std(values: list[float]) -> float: + if len(values) < 2: + return 0.0 + center = mean(values) + variance = sum((value - center) ** 2 for value in values) / len(values) + return sqrt(variance) + + +def _severity_from_score(score: float) -> str: + absolute = abs(score) + if absolute >= 3.5: + return "critical" + if absolute >= 2.5: + return "high" + if absolute >= 1.5: + return "medium" + return "low" + + +def _history_series(history: list[Any], field_name: str) -> tuple[list[float], str | None, float | None]: + values: list[float] = [] + latest_timestamp = None + latest_value = None + + for item in history: + value = getattr(item, field_name, None) + if value is None: + continue + numeric = float(value) + values.append(numeric) + if latest_timestamp is None: + recorded_at = getattr(item, "recorded_at", None) + latest_timestamp = recorded_at.isoformat() if recorded_at is not None else None + latest_value = numeric + + return list(reversed(values)), latest_timestamp, latest_value + + +def _forecast_series(forecasts: list[Any], field_name: str) -> tuple[list[float], str | None, float | None]: + values: list[float] = [] + latest_timestamp = None + latest_value = None + + for forecast in forecasts[:7]: + value = getattr(forecast, field_name, None) + if value is None: + continue + numeric = float(value) + values.append(numeric) + if latest_timestamp is None: + forecast_date = getattr(forecast, "forecast_date", None) + latest_timestamp = forecast_date.isoformat() if forecast_date is not None else None + latest_value = numeric + + return values, latest_timestamp, latest_value + + +def _detect_with_z_score(values: list[float], observed_value: float) -> dict[str, Any] | None: + if len(values) < 5: + return None + center = mean(values) + std = _population_std(values) + if std == 0: + return None + score = (observed_value - center) / std + if abs(score) < 2.0: + return None + return { + "anomaly_method": "Z_SCORE", + "deviation_score": round(score, 3), + "expected_range": [round(center - (2 * std), 2), round(center + (2 * std), 2)], + "severity": _severity_from_score(score), + } + + +def _detect_with_iqr(values: list[float], observed_value: float) -> dict[str, Any] | None: + if len(values) < 5: + return None + sorted_values = sorted(values) + q1 = _percentile(sorted_values, 0.25) + q3 = _percentile(sorted_values, 0.75) + iqr = q3 - q1 + if iqr == 0: + return None + lower = q1 - (1.5 * iqr) + upper = q3 + (1.5 * iqr) + if lower <= observed_value <= upper: + return None + + if observed_value < lower: + score = (observed_value - lower) / iqr + else: + score = (observed_value - upper) / iqr + + return { + "anomaly_method": "IQR", + "deviation_score": round(score, 3), + "expected_range": [round(lower, 2), round(upper, 2)], + "severity": _severity_from_score(score), + } + + +def _select_detection_result(results: list[dict[str, Any]]) -> dict[str, Any] | None: + if not results: + return None + return sorted( + results, + key=lambda item: (METHOD_PRIORITY[item["anomaly_method"]], abs(item["deviation_score"])), + reverse=True, + )[0] + + +def _build_contextual_interpretation(anomalies: list[dict[str, Any]], ai_bundle: dict | None = None) -> dict[str, Any]: + ai_bundle = ai_bundle or {} + ai_payload = ai_bundle.get("anomalyDetectionCard", {}) if isinstance(ai_bundle, dict) else {} + if isinstance(ai_payload, dict) and all(ai_payload.get(key) for key in ("explanation", "likely_cause", "recommended_action")): + return { + "explanation": ai_payload["explanation"], + "likely_cause": ai_payload["likely_cause"], + "recommended_action": ai_payload["recommended_action"], + } + + metric_types = {item["metric_type"] for item in anomalies} + if {"soil_temperature", "soil_moisture"} <= metric_types: + return { + "explanation": "هم‌زمانی ناهنجاری دمای خاک و رطوبت خاک نشان می‌دهد تنش ترکیبی در ناحیه ریشه در حال شکل‌گیری است.", + "likely_cause": "احتمالاً الگوی آبیاری، موج گرما یا افت ناگهانی ظرفیت نگهداشت رطوبت خاک عامل اصلی است.", + "recommended_action": "زمان‌بندی آبیاری و وضعیت زهکشی/تبخیر بررسی و قرائت‌های سنسور در ۲۴ ساعت آینده دوباره پایش شود.", + } + if "electrical_conductivity" in metric_types and "soil_moisture" in metric_types: + return { + "explanation": "هم‌زمانی ناهنجاری EC و رطوبت می‌تواند نشان‌دهنده فشار شوری یا تجمع نمک در بستر باشد.", + "likely_cause": "کیفیت آب آبیاری، کوددهی اخیر یا کاهش شست‌وشوی خاک می‌تواند عامل این الگو باشد.", + "recommended_action": "EC آب و برنامه کوددهی بازبینی و در صورت نیاز شست‌وشوی کنترل‌شده خاک بررسی شود.", + } + if anomalies: + top = anomalies[0] + return { + "explanation": f"در شاخص {top['label']} یک ناهنجاری آماری با روش {top['anomaly_method']} شناسایی شده است.", + "likely_cause": "این رخداد می‌تواند ناشی از تغییر ناگهانی شرایط محیطی، خطای فرایندی یا نیاز به کالیبراسیون سنسور باشد.", + "recommended_action": "روند همان شاخص و داده‌های پیرامونی بازبینی و در صورت تداوم، اقدام اصلاحی مزرعه اجرا شود.", + } + return { + "explanation": "ناهنجاری آماری معناداری در داده‌های اخیر شناسایی نشد.", + "likely_cause": "داده‌های فعلی با الگوی تاریخی سازگار هستند.", + "recommended_action": "پایش عادی ادامه یابد.", + } + + +def build_anomaly_detection_card(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict: + context = context or {} + sensor = context.get("sensor") + history = context.get("history", []) + forecasts = context.get("forecasts", []) + if sensor is None: + return {"anomalies": [], "interpretation": None} + + anomalies: list[dict[str, Any]] = [] + + for metric_type, config in METRIC_CONFIG.items(): + if config["source"] == "history": + values, timestamp, observed_value = _history_series(history, config["current_field"]) + current_value = getattr(sensor, config["current_field"], None) + if current_value is not None: + observed_value = float(current_value) + timestamp = getattr(sensor, "updated_at", None) + timestamp = timestamp.isoformat() if timestamp is not None else timestamp + else: + values, timestamp, observed_value = _forecast_series(forecasts, config["forecast_field"]) + + if observed_value is None or len(values) < 5: + continue + + detection = _select_detection_result( + [ + result + for result in ( + _detect_with_z_score(values, observed_value), + _detect_with_iqr(values, observed_value), + ) + if result is not None + ] + ) + if detection is None: + continue + + anomalies.append( + { + "metric_type": metric_type, + "label": config["label"], + "timestamp": timestamp, + "observed_value": round(observed_value, 2), + "expected_range": detection["expected_range"], + "deviation_score": detection["deviation_score"], + "anomaly_method": detection["anomaly_method"], + "severity": detection["severity"], + "unit": config["unit"], + } + ) + + anomalies.sort(key=lambda item: abs(item["deviation_score"]), reverse=True) + interpretation = _build_contextual_interpretation(anomalies, ai_bundle=ai_bundle) + + return { + "anomalies": anomalies, + "interpretation": interpretation, + } diff --git a/Modules/Ai/soile/apps.py b/Modules/Ai/soile/apps.py new file mode 100644 index 0000000..4a712a8 --- /dev/null +++ b/Modules/Ai/soile/apps.py @@ -0,0 +1,36 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class SoileConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "soile" + verbose_name = "Soile" + + @cached_property + def soil_moisture_service(self): + from .services import SoilMoistureHeatmapService + + return SoilMoistureHeatmapService() + + def get_soil_moisture_service(self): + return self.soil_moisture_service + + @cached_property + def soil_health_service(self): + from .services import SoilHealthService + + return SoilHealthService() + + def get_soil_health_service(self): + return self.soil_health_service + + @cached_property + def soil_anomaly_service(self): + from .services import SoilAnomalyDetectionService + + return SoilAnomalyDetectionService() + + def get_soil_anomaly_service(self): + return self.soil_anomaly_service diff --git a/Modules/Ai/soile/health_summary.py b/Modules/Ai/soile/health_summary.py new file mode 100644 index 0000000..f1ac542 --- /dev/null +++ b/Modules/Ai/soile/health_summary.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Any + + +DEFAULT_HEALTH_PROFILE = { + "moisture": {"ideal_value": 65.0, "min_range": 45.0, "max_range": 75.0, "weight": 0.45}, + "ph": {"ideal_value": 6.6, "min_range": 6.0, "max_range": 7.5, "weight": 0.30}, + "ec": {"ideal_value": 1.2, "min_range": 0.2, "max_range": 3.0, "weight": 0.25}, +} + +METRIC_SPECS = { + "moisture": {"sensor_field": "soil_moisture", "label": "رطوبت خاک", "unit": "%"}, + "ph": {"sensor_field": "soil_ph", "label": "pH خاک", "unit": "pH"}, + "ec": {"sensor_field": "electrical_conductivity", "label": "هدایت الکتریکی", "unit": "dS/m"}, +} + + +def _safe_number(value: Any, default: float = 0.0) -> float: + return default if value is None else float(value) + + +def _normalize_metric(value: float, ideal_value: float, min_range: float, max_range: float) -> float: + if max_range <= min_range: + return 0.0 + if value <= min_range or value >= max_range: + return 0.0 + if value == ideal_value: + return 1.0 + if value < ideal_value: + span = ideal_value - min_range + if span <= 0: + return 0.0 + return max(0.0, min(1.0, (value - min_range) / span)) + span = max_range - ideal_value + if span <= 0: + return 0.0 + return max(0.0, min(1.0, (max_range - value) / span)) + + +def resolve_plant_profile(plants: list[Any]) -> tuple[dict[str, dict[str, float]], str]: + for plant in plants: + profile = getattr(plant, "health_profile", None) or {} + if profile: + merged = { + metric: { + **DEFAULT_HEALTH_PROFILE.get(metric, {}), + **profile.get(metric, {}), + } + for metric in set(DEFAULT_HEALTH_PROFILE) | set(profile) + } + return merged, getattr(plant, "name", "گیاه") + return DEFAULT_HEALTH_PROFILE, (plants[0].name if plants else "پروفایل پیش‌فرض") + + +def compute_health_score(sensor: Any, profile: dict[str, dict[str, float]]) -> tuple[int, list[dict[str, Any]]]: + weighted_sum = 0.0 + total_weight = 0.0 + components: list[dict[str, Any]] = [] + + for metric_type, config in profile.items(): + spec = METRIC_SPECS.get(metric_type) + if spec is None: + continue + + sensor_value = getattr(sensor, spec["sensor_field"], None) + if sensor_value is None: + continue + + current_value = _safe_number(sensor_value, 0) + defaults = DEFAULT_HEALTH_PROFILE.get(metric_type, {}) + ideal_value = float(config.get("ideal_value", defaults.get("ideal_value", 0))) + min_range = float(config.get("min_range", defaults.get("min_range", 0))) + max_range = float(config.get("max_range", defaults.get("max_range", 0))) + weight = float(config.get("weight", defaults.get("weight", 0))) + if weight <= 0: + continue + + normalized_value = _normalize_metric(current_value, ideal_value, min_range, max_range) + weighted_sum += weight * normalized_value + total_weight += weight + components.append( + { + "metricType": metric_type, + "label": spec["label"], + "unit": spec["unit"], + "currentValue": round(current_value, 2), + "idealValue": round(ideal_value, 2), + "minRange": round(min_range, 2), + "maxRange": round(max_range, 2), + "weight": round(weight, 3), + "normalizedValue": round(normalized_value, 4), + "weightedContribution": round(weight * normalized_value, 4), + } + ) + + if total_weight <= 0: + return 0, components + + score = round((weighted_sum / total_weight) * 100) + return max(0, min(100, score)), components + + +def health_language(health_score: int) -> dict[str, str]: + if health_score >= 85: + return { + "short_chip_text": "بسیار خوب", + "action_hint": "برنامه فعلی پایش و نگهداری حفظ شود.", + "explanation": "بیشتر شاخص های کلیدی نزدیک به پروفایل ایده آل گیاه هستند.", + } + if health_score >= 70: + return { + "short_chip_text": "پایدار", + "action_hint": "تنظیمات فعلی حفظ و فقط شاخص های مرزی پایش شوند.", + "explanation": "وضعیت کلی مزرعه قابل قبول است اما بعضی شاخص ها هنوز جای بهبود دارند.", + } + if health_score >= 50: + return { + "short_chip_text": "نیازمند تنظیم", + "action_hint": "پارامترهای دور از محدوده ایده آل در اولویت اصلاح قرار گیرند.", + "explanation": "بخشی از شرایط محیطی از پروفایل مطلوب گیاه فاصله گرفته است.", + } + return { + "short_chip_text": "تنش بالا", + "action_hint": "اصلاح فوری رطوبت، تغذیه يا شوری بر اساس اجزای امتیاز انجام شود.", + "explanation": "چند شاخص اصلی خارج از بازه قابل قبول گیاه هستند.", + } + + +def build_soil_health_summary(sensor: Any, plants: list[Any]) -> dict[str, Any]: + profile, profile_source = resolve_plant_profile(plants) + health_score, health_components = compute_health_score(sensor, profile) + moisture = _safe_number(getattr(sensor, "soil_moisture", None), 0) + language = health_language(health_score) + return { + "healthScore": health_score, + "profileSource": profile_source, + "healthScoreDetails": { + "method": "normalized_weighted_average", + "profileSource": profile_source, + "components": health_components, + }, + "healthLanguage": language, + "avgSoilMoisture": round(moisture), + "avgSoilMoistureRaw": round(moisture, 2), + "avgSoilMoistureStatus": "بهینه" if 45 <= moisture <= 75 else "نیازمند بررسی", + } diff --git a/Modules/Ai/soile/serializers.py b/Modules/Ai/soile/serializers.py new file mode 100644 index 0000000..9c95e79 --- /dev/null +++ b/Modules/Ai/soile/serializers.py @@ -0,0 +1,44 @@ +from rest_framework import serializers + + +class SoilMoistureHeatmapRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilMoistureHeatmapResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + location = serializers.JSONField() + current_sensor = serializers.JSONField() + soil_profile = serializers.JSONField() + timestamp = serializers.CharField(allow_null=True) + grid_resolution = serializers.JSONField(allow_null=True) + grid_cells = serializers.JSONField() + sensor_points = serializers.JSONField() + quality_legend = serializers.JSONField() + + +class SoilAnomalyDetectionRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilHealthSummaryRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class SoilHealthSummaryResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + healthScore = serializers.IntegerField() + profileSource = serializers.CharField() + healthScoreDetails = serializers.JSONField() + healthLanguage = serializers.JSONField() + avgSoilMoisture = serializers.IntegerField() + avgSoilMoistureRaw = serializers.FloatField() + avgSoilMoistureStatus = serializers.CharField() + + +class SoilAnomalyDetectionResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + generated_at = serializers.CharField() + anomalies = serializers.JSONField() + interpretation = serializers.JSONField() + raw_response = serializers.CharField(allow_null=True, required=False) diff --git a/Modules/Ai/soile/services.py b/Modules/Ai/soile/services.py new file mode 100644 index 0000000..6066581 --- /dev/null +++ b/Modules/Ai/soile/services.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +from datetime import datetime +from math import sqrt +from statistics import median +from typing import Any + +from django.utils import timezone + +from farm_data.context import load_farm_context +from farm_data.models import SensorData +from location_data.satellite_snapshot import build_location_satellite_snapshot +from rag.services import get_soil_anomaly_insight + +from .anomaly_detection import build_anomaly_detection_card +from .health_summary import build_soil_health_summary + + +QUALITY_REAL = "REAL" +QUALITY_INTERPOLATED = "INTERPOLATED" +QUALITY_MISSING = "MISSING" +QUALITY_EXTRAPOLATED = "EXTRAPOLATED" + +IDW_POWER = 2 +MAX_GRID_STEPS = 10 +FRESHNESS_HALF_LIFE_HOURS = 24.0 +MAX_SENSOR_INFLUENCE_DISTANCE = 0.08 + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + return float(value) + except (TypeError, ValueError): + return None + + +def _sensor_time_series(sensor: Any) -> list[dict[str, Any]]: + sensor_block = sensor.get_sensor_block() if hasattr(sensor, "get_sensor_block") else {} + soil_moisture = _safe_float(getattr(sensor, "soil_moisture", None)) + measured_at = sensor_block.get("timestamp") or sensor_block.get("measured_at") + if measured_at is None and getattr(sensor, "updated_at", None): + measured_at = sensor.updated_at.isoformat() + return [ + { + "timestamp": measured_at, + "value": soil_moisture, + "quality_flag": QUALITY_REAL if soil_moisture is not None else QUALITY_MISSING, + } + ] + + +def _parse_timestamp(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + if not value: + return None + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + return parsed + return None + + +def _hours_since(timestamp: Any) -> float | None: + parsed = _parse_timestamp(timestamp) + if parsed is None: + return None + delta = timezone.now() - parsed + return max(delta.total_seconds() / 3600.0, 0.0) + + +def _freshness_weight(timestamp: Any) -> float: + age_hours = _hours_since(timestamp) + if age_hours is None: + return 0.65 + return 1.0 / (1.0 + (age_hours / FRESHNESS_HALF_LIFE_HOURS)) + + +def _sensor_anomaly_penalty(value: float | None, network_values: list[float]) -> float: + if value is None or len(network_values) < 3: + return 1.0 + + center = median(network_values) + deviations = [abs(item - center) for item in network_values] + typical_deviation = median(deviations) or 1.0 + normalized_distance = abs(value - center) / typical_deviation + return max(0.45, min(1.0, 1.15 - (normalized_distance * 0.18))) + + +def _boundary_points(sensor: Any) -> list[tuple[float, float]]: + boundary = getattr(sensor.center_location, "farm_boundary", None) or {} + coordinates = [] + if isinstance(boundary, dict) and boundary.get("type") == "Polygon": + coordinates = boundary.get("coordinates") or [] + if coordinates and isinstance(coordinates[0], list): + return [(float(point[1]), float(point[0])) for point in coordinates[0] if len(point) >= 2] + corners = boundary.get("corners") if isinstance(boundary, dict) else boundary if isinstance(boundary, list) else [] + points = [] + for point in corners or []: + if isinstance(point, dict) and point.get("lat") is not None and point.get("lon") is not None: + points.append((float(point["lat"]), float(point["lon"]))) + return points + + +def _point_in_polygon(lat: float, lon: float, polygon: list[tuple[float, float]]) -> bool: + if len(polygon) < 3: + return True + + inside = False + for index in range(len(polygon)): + lat1, lon1 = polygon[index] + lat2, lon2 = polygon[(index + 1) % len(polygon)] + intersects = ((lon1 > lon) != (lon2 > lon)) and ( + lat < ((lat2 - lat1) * (lon - lon1) / max(lon2 - lon1, 1e-12)) + lat1 + ) + if intersects: + inside = not inside + return inside + + +def _latest_sensor_measurement(sensor: Any, network_values: list[float]) -> dict[str, Any]: + series = _sensor_time_series(sensor) + latest = series[-1] if series else {"timestamp": None, "value": None, "quality_flag": QUALITY_MISSING} + reliability = _sensor_anomaly_penalty(latest["value"], network_values) * _freshness_weight(latest["timestamp"]) + return { + "sensor_id": str(sensor.farm_uuid), + "latitude": float(sensor.center_location.latitude), + "longitude": float(sensor.center_location.longitude), + "depth": None, + "timestamp": latest["timestamp"], + "soil_moisture_value": latest["value"], + "quality_flag": latest["quality_flag"], + "freshness_weight": round(_freshness_weight(latest["timestamp"]), 4), + "reliability_score": round(reliability, 4), + } + + +def _spatial_weight(distance: float) -> float: + if distance == 0: + return 1.0 + if distance > MAX_SENSOR_INFLUENCE_DISTANCE: + return 0.0 + return 1 / (distance**IDW_POWER) + + +def _interpolate_cell( + lat: float, + lon: float, + sensor_points: list[dict[str, Any]], +) -> tuple[float | None, str, float]: + weighted_sum = 0.0 + weight_total = 0.0 + min_distance = None + + for point in sensor_points: + value = point["soil_moisture_value"] + if value is None: + continue + distance = sqrt(((lat - point["latitude"]) ** 2) + ((lon - point["longitude"]) ** 2)) + min_distance = distance if min_distance is None else min(min_distance, distance) + if distance == 0: + return round(float(value), 2), point["quality_flag"], 1.0 + + spatial_weight = _spatial_weight(distance) + if spatial_weight == 0.0: + continue + composite_weight = spatial_weight * float(point.get("reliability_score", 1.0)) + weighted_sum += composite_weight * float(value) + weight_total += composite_weight + + if weight_total == 0.0: + return None, QUALITY_MISSING, 0.0 + + uncertainty = 1.0 - min(weight_total / (weight_total + 6.0), 1.0) + quality_flag = QUALITY_INTERPOLATED + if min_distance is not None and min_distance > (MAX_SENSOR_INFLUENCE_DISTANCE / 2): + quality_flag = QUALITY_EXTRAPOLATED + + return round(weighted_sum / weight_total, 2), quality_flag, round(max(0.0, min(1.0, uncertainty)), 4) + + +def _grid_axis(min_value: float, max_value: float) -> list[float]: + if min_value == max_value: + return [round(min_value, 6)] + step_count = min(MAX_GRID_STEPS, max(int((max_value - min_value) / 0.0001) + 1, 2)) + step = (max_value - min_value) / (step_count - 1) + return [round(min_value + (step * index), 6) for index in range(step_count)] + + +def _load_sensor_network(current_sensor: Any) -> list[Any]: + plant_ids = list( + current_sensor.plant_assignments.values_list("plant__backend_plant_id", flat=True) + ) + queryset = SensorData.objects.select_related("center_location").prefetch_related( + "plant_assignments__plant", + ) + if plant_ids: + queryset = queryset.filter( + plant_assignments__plant__backend_plant_id__in=plant_ids + ).distinct() + return list(queryset) + + +def _soil_profile(sensor: Any) -> list[dict[str, Any]]: + snapshot = build_location_satellite_snapshot(sensor.center_location) + metrics = snapshot.get("resolved_metrics") or {} + if not metrics: + return [] + return [ + { + "depth_label": "surface_30x30_remote_sensing", + "field_capacity": metrics.get("ndwi"), + "wilting_point": None, + "saturation": None, + "nitrogen": None, + "ph": None, + "sand": None, + "silt": None, + "clay": None, + "ndvi": metrics.get("ndvi"), + "lst_c": metrics.get("lst_c"), + "soil_vv_db": metrics.get("soil_vv_db"), + "dem_m": metrics.get("dem_m"), + "slope_deg": metrics.get("slope_deg"), + } + ] + + +def _depth_layers(soil_profile: list[dict[str, Any]], grid_cells: list[dict[str, Any]]) -> list[dict[str, Any]]: + layers = [] + if not soil_profile or not grid_cells: + return layers + + for index, depth in enumerate(soil_profile): + depth_factor = max(0.72, 1.0 - (index * 0.08)) + layer_cells = [] + for cell in grid_cells: + if cell["moisture_value"] is None: + moisture_value = None + else: + moisture_value = round(cell["moisture_value"] * depth_factor, 2) + layer_cells.append( + { + "lat": cell["lat"], + "lon": cell["lon"], + "moisture_value": moisture_value, + "quality_flag": cell["quality_flag"], + "uncertainty": cell.get("uncertainty"), + } + ) + layers.append( + { + "depth_label": depth.get("depth_label"), + "estimated_from_surface": True, + "cells": layer_cells, + } + ) + return layers + + +def _heatmap_summary(sensor_points: list[dict[str, Any]], grid_cells: list[dict[str, Any]]) -> dict[str, Any]: + sensor_values = [point["soil_moisture_value"] for point in sensor_points if point["soil_moisture_value"] is not None] + uncertainties = [cell["uncertainty"] for cell in grid_cells if cell.get("uncertainty") is not None] + return { + "sensor_count": len(sensor_points), + "active_sensor_count": len(sensor_values), + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "uses_freshness_weighting": True, + "uses_boundary_mask": True, + "uses_outlier_penalty": True, + "avg_sensor_moisture": round(sum(sensor_values) / len(sensor_values), 2) if sensor_values else None, + "avg_uncertainty": round(sum(uncertainties) / len(uncertainties), 4) if uncertainties else None, + } + + +class SoilMoistureHeatmapService: + def get_heatmap(self, *, farm_uuid: str) -> dict[str, Any]: + current_sensor = ( + SensorData.objects.select_related("center_location") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=farm_uuid) + .first() + ) + if current_sensor is None: + raise ValueError("Farm not found.") + + sensors = _load_sensor_network(current_sensor) + raw_network_values = [ + _safe_float(getattr(sensor, "soil_moisture", None)) + for sensor in sensors + if _safe_float(getattr(sensor, "soil_moisture", None)) is not None + ] + sensor_points = [_latest_sensor_measurement(sensor, raw_network_values) for sensor in sensors] + valid_sensor_points = [point for point in sensor_points if point["soil_moisture_value"] is not None] + soil_profile = _soil_profile(current_sensor) + farm_polygon = _boundary_points(current_sensor) + + if not valid_sensor_points: + return { + "farm_uuid": str(current_sensor.farm_uuid), + "location": { + "lat": float(current_sensor.center_location.latitude), + "lon": float(current_sensor.center_location.longitude), + }, + "current_sensor": { + "soil_moisture": current_sensor.soil_moisture, + "soil_temperature": current_sensor.soil_temperature, + "soil_ph": current_sensor.soil_ph, + "electrical_conductivity": current_sensor.electrical_conductivity, + }, + "soil_profile": soil_profile, + "depth_layers": [], + "timestamp": current_sensor.updated_at.isoformat() if getattr(current_sensor, "updated_at", None) else None, + "grid_resolution": None, + "grid_cells": [], + "sensor_points": sensor_points, + "model_metadata": { + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "limitations": [ + "history واقعی سنسورها در مدل حاضر در دسترس نیست", + "depth layers از surface estimate مشتق می‌شوند", + ], + }, + "summary": _heatmap_summary(sensor_points, []), + "quality_legend": { + QUALITY_REAL: "اندازه گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + QUALITY_EXTRAPOLATED: "برآورد در ناحیه دور از سنسورها", + }, + } + + if farm_polygon: + min_lat = min(point[0] for point in farm_polygon) + max_lat = max(point[0] for point in farm_polygon) + min_lon = min(point[1] for point in farm_polygon) + max_lon = max(point[1] for point in farm_polygon) + else: + min_lat = min(point["latitude"] for point in valid_sensor_points) + max_lat = max(point["latitude"] for point in valid_sensor_points) + min_lon = min(point["longitude"] for point in valid_sensor_points) + max_lon = max(point["longitude"] for point in valid_sensor_points) + + lat_axis = _grid_axis(min_lat, max_lat) + lon_axis = _grid_axis(min_lon, max_lon) + + grid_cells = [] + for lat in lat_axis: + for lon in lon_axis: + if farm_polygon and not _point_in_polygon(lat, lon, farm_polygon): + grid_cells.append( + { + "lat": lat, + "lon": lon, + "moisture_value": None, + "quality_flag": QUALITY_MISSING, + "uncertainty": None, + "inside_farm_boundary": False, + } + ) + continue + + direct_sensor = next( + ( + point + for point in valid_sensor_points + if point["latitude"] == lat and point["longitude"] == lon + ), + None, + ) + if direct_sensor is not None: + moisture_value = direct_sensor["soil_moisture_value"] + quality_flag = direct_sensor["quality_flag"] + uncertainty = round(1.0 - float(direct_sensor.get("reliability_score", 1.0)), 4) + else: + moisture_value, quality_flag, uncertainty = _interpolate_cell(lat, lon, valid_sensor_points) + + grid_cells.append( + { + "lat": lat, + "lon": lon, + "moisture_value": moisture_value, + "quality_flag": quality_flag, + "uncertainty": uncertainty if moisture_value is not None else None, + "inside_farm_boundary": True, + } + ) + + lat_step = round(abs(lat_axis[1] - lat_axis[0]), 6) if len(lat_axis) > 1 else 0.0 + lon_step = round(abs(lon_axis[1] - lon_axis[0]), 6) if len(lon_axis) > 1 else 0.0 + timestamps = [point["timestamp"] for point in sensor_points if point["timestamp"]] + depth_layers = _depth_layers(soil_profile, [cell for cell in grid_cells if cell["inside_farm_boundary"]]) + + return { + "farm_uuid": str(current_sensor.farm_uuid), + "location": { + "lat": float(current_sensor.center_location.latitude), + "lon": float(current_sensor.center_location.longitude), + }, + "current_sensor": { + "soil_moisture": current_sensor.soil_moisture, + "soil_temperature": current_sensor.soil_temperature, + "soil_ph": current_sensor.soil_ph, + "electrical_conductivity": current_sensor.electrical_conductivity, + "nitrogen": current_sensor.nitrogen, + "phosphorus": current_sensor.phosphorus, + "potassium": current_sensor.potassium, + }, + "soil_profile": soil_profile, + "depth_layers": depth_layers, + "timestamp": max(timestamps) if timestamps else None, + "grid_resolution": { + "lat_step": lat_step, + "lon_step": lon_step, + "rows": len(lat_axis), + "cols": len(lon_axis), + }, + "grid_cells": grid_cells, + "sensor_points": sensor_points, + "model_metadata": { + "interpolation_model": "boundary_aware_weighted_idw", + "uses_sensor_history": False, + "uses_freshness_weighting": True, + "uses_outlier_penalty": True, + "uses_depth_estimation": True, + "uses_boundary_mask": bool(farm_polygon), + "limitations": [ + "history واقعی سنسورها در مدل حاضر ذخیره نشده است", + "depth layers از داده سطحی و پروفایل خاک مشتق شده‌اند", + "uncertainty به صورت heuristic برآورد می‌شود", + ], + }, + "summary": _heatmap_summary(sensor_points, [cell for cell in grid_cells if cell["inside_farm_boundary"]]), + "quality_legend": { + QUALITY_REAL: "اندازه گیری واقعی سنسور", + QUALITY_INTERPOLATED: "مقدار برآوردشده با وزن دهی زمانی/فضایی", + QUALITY_MISSING: "داده معتبر در دسترس نیست", + QUALITY_EXTRAPOLATED: "برآورد در ناحیه دور از سنسورها", + }, + } + + +class SoilHealthService: + def get_health_summary(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + return { + "farm_uuid": str(sensor.farm_uuid), + **build_soil_health_summary( + sensor, + list(sensor.plant_snapshots), + ), + } + + +class SoilAnomalyDetectionService: + def get_anomaly_detection(self, *, farm_uuid: str) -> dict[str, Any]: + context = load_farm_context(farm_uuid) + if context is None: + raise ValueError("Farm not found.") + + anomaly_payload = build_anomaly_detection_card( + sensor_id=farm_uuid, + context=context, + ai_bundle=None, + ) + rag_payload = get_soil_anomaly_insight( + farm_uuid=farm_uuid, + anomaly_payload=anomaly_payload, + ) + return { + "farm_uuid": farm_uuid, + **anomaly_payload, + **rag_payload, + } diff --git a/Modules/Ai/soile/test_soil_moisture_heatmap_api.py b/Modules/Ai/soile/test_soil_moisture_heatmap_api.py new file mode 100644 index 0000000..3a36d56 --- /dev/null +++ b/Modules/Ai/soile/test_soil_moisture_heatmap_api.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from rag.failure_contract import RAGServiceError +from soile.services import SoilMoistureHeatmapService + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilMoistureHeatmapApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_heatmap_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_heatmap=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "location": {"lat": 35.7, "lon": 51.4}, + "current_sensor": {"soil_moisture": 22.5}, + "soil_profile": [{"depth_label": "0-5cm", "field_capacity": 0.34}], + "timestamp": "2026-04-01T00:00:00", + "grid_resolution": {"lat_step": 0.001, "lon_step": 0.001, "rows": 2, "cols": 2}, + "grid_cells": [{"lat": 35.7, "lon": 51.4, "moisture_value": 22.5, "quality_flag": "REAL"}], + "sensor_points": [{"sensor_id": "farm-1", "soil_moisture_value": 22.5}], + "quality_legend": {"REAL": "اندازه گیری واقعی سنسور"}, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_moisture_service=lambda: mock_service + ) + + response = self.client.post( + "/moisture-heatmap/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["current_sensor"]["soil_moisture"], 22.5) + + @patch("soile.views.apps.get_app_config") + def test_heatmap_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_heatmap=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_moisture_service=lambda: mock_service + ) + + response = self.client.post( + "/moisture-heatmap/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilAnomalyDetectionApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "generated_at": "2026-04-01T00:00:00", + "anomalies": [ + { + "metric_type": "soil_moisture", + "label": "رطوبت خاک", + "severity": "high", + "observed_value": 21.4, + } + ], + "interpretation": { + "summary": "ناهنجاري در رطوبت خاک شناسايي شد.", + "explanation": "رطوبت خاک از الگوي معمول فاصله گرفته است.", + "likely_cause": "احتمال اختلال در آبياري يا افزايش تبخير.", + "recommended_action": "آبياري و قرائت سنسور بازبيني شود.", + "monitoring_priority": "urgent", + "confidence": 0.84, + }, + "raw_response": "{\"summary\":\"ok\"}", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["interpretation"]["monitoring_priority"], "urgent") + + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + @patch("soile.views.apps.get_app_config") + def test_anomaly_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw( + RAGServiceError( + error_code="invalid_json", + message="Soil anomaly LLM response was not valid JSON.", + source="llm", + retriable=True, + http_status=502, + ) + ) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_anomaly_service=lambda: mock_service + ) + + response = self.client.post( + "/anomaly-detection/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["data"]["error_code"], "invalid_json") + + +class SoilMoistureHeatmapServiceTests(TestCase): + @patch("soile.services.SensorData.objects") + def test_heatmap_service_builds_boundary_aware_weighted_output(self, mock_objects): + now = timezone.now() + boundary = { + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.70], + [51.41, 35.70], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.70], + ] + ], + } + depth = SimpleNamespace( + depth_label="0-5cm", + wv0033=0.34, + wv1500=0.14, + wv0010=0.40, + nitrogen=12.0, + phh2o=7.1, + sand=40.0, + silt=35.0, + clay=25.0, + ) + plants = SimpleNamespace(values_list=lambda *args, **kwargs: [1]) + center_a = SimpleNamespace(latitude=35.70, longitude=51.39, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) + center_b = SimpleNamespace(latitude=35.72, longitude=51.41, farm_boundary=boundary, depths=SimpleNamespace(all=lambda: [depth])) + sensor_a = SimpleNamespace( + farm_uuid="farm-a", + center_location=center_a, + plants=plants, + sensor_payload={"sensor-1": {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}}, + updated_at=now - timedelta(hours=2), + get_sensor_block=lambda: {"soil_moisture": 20.0, "timestamp": (now - timedelta(hours=2)).isoformat()}, + soil_moisture=20.0, + soil_temperature=18.0, + soil_ph=7.0, + electrical_conductivity=1.2, + nitrogen=10.0, + phosphorus=8.0, + potassium=12.0, + ) + sensor_b = SimpleNamespace( + farm_uuid="farm-b", + center_location=center_b, + plants=plants, + sensor_payload={"sensor-1": {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}}, + updated_at=now - timedelta(hours=30), + get_sensor_block=lambda: {"soil_moisture": 36.0, "timestamp": (now - timedelta(hours=30)).isoformat()}, + soil_moisture=36.0, + soil_temperature=19.0, + soil_ph=7.2, + electrical_conductivity=1.3, + nitrogen=11.0, + phosphorus=8.5, + potassium=12.5, + ) + + current_first = MagicMock() + current_first.first.return_value = sensor_a + current_filter = MagicMock() + current_filter.filter.return_value = current_first + current_qs = MagicMock() + current_qs.prefetch_related.return_value = current_filter + + network_distinct = MagicMock() + network_distinct.distinct.return_value = [sensor_a, sensor_b] + network_filter = MagicMock() + network_filter.filter.return_value = network_distinct + network_qs = MagicMock() + network_qs.prefetch_related.return_value = network_filter + + mock_objects.select_related.side_effect = [current_qs, network_qs] + + payload = SoilMoistureHeatmapService().get_heatmap(farm_uuid="farm-a") + + self.assertEqual(payload["model_metadata"]["interpolation_model"], "boundary_aware_weighted_idw") + self.assertTrue(payload["model_metadata"]["uses_freshness_weighting"]) + self.assertTrue(payload["model_metadata"]["uses_boundary_mask"]) + self.assertEqual(payload["summary"]["active_sensor_count"], 2) + self.assertEqual(payload["depth_layers"][0]["depth_label"], "0-5cm") + self.assertGreater(payload["sensor_points"][0]["reliability_score"], payload["sensor_points"][1]["reliability_score"]) + outside_cells = [cell for cell in payload["grid_cells"] if not cell["inside_farm_boundary"]] + self.assertTrue(outside_cells) + self.assertTrue(all(cell["moisture_value"] is None for cell in outside_cells)) + self.assertIn("uncertainty", payload["grid_cells"][0]) + + +@override_settings(ROOT_URLCONF="soile.urls") +class SoilHealthSummaryApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("soile.views.apps.get_app_config") + def test_health_summary_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_health_summary=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "healthScore": 82, + "profileSource": "گوجه فرنگی", + "healthScoreDetails": {"components": []}, + "healthLanguage": {"short_chip_text": "پایدار"}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 45.8, + "avgSoilMoistureStatus": "بهینه", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_soil_health_service=lambda: mock_service + ) + + response = self.client.post( + "/health-summary/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["healthScore"], 82) + self.assertEqual(payload["avgSoilMoistureStatus"], "بهینه") diff --git a/Modules/Ai/soile/urls.py b/Modules/Ai/soile/urls.py new file mode 100644 index 0000000..cf7c93e --- /dev/null +++ b/Modules/Ai/soile/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import SoilAnomalyDetectionView, SoilHealthSummaryView, SoilMoistureHeatmapView + + +urlpatterns = [ + path("anomaly-detection/", SoilAnomalyDetectionView.as_view(), name="soil-anomaly-detection"), + path("health-summary/", SoilHealthSummaryView.as_view(), name="soil-health-summary"), + path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"), +] diff --git a/Modules/Ai/soile/views.py b/Modules/Ai/soile/views.py new file mode 100644 index 0000000..7921801 --- /dev/null +++ b/Modules/Ai/soile/views.py @@ -0,0 +1,197 @@ +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError + +from .serializers import ( + SoilAnomalyDetectionRequestSerializer, + SoilAnomalyDetectionResponseSerializer, + SoilHealthSummaryRequestSerializer, + SoilHealthSummaryResponseSerializer, + SoilMoistureHeatmapRequestSerializer, + SoilMoistureHeatmapResponseSerializer, +) + + +SoileHeatmapEnvelopeSerializer = build_envelope_serializer( + "SoileHeatmapEnvelopeSerializer", + SoilMoistureHeatmapResponseSerializer, +) +SoileErrorSerializer = build_envelope_serializer( + "SoileErrorSerializer", + data_required=False, + allow_null=True, +) +SoileAnomalyEnvelopeSerializer = build_envelope_serializer( + "SoileAnomalyEnvelopeSerializer", + SoilAnomalyDetectionResponseSerializer, +) +SoileHealthEnvelopeSerializer = build_envelope_serializer( + "SoileHealthEnvelopeSerializer", + SoilHealthSummaryResponseSerializer, +) + + +class SoilMoistureHeatmapView(APIView): + @extend_schema( + tags=["Soile"], + summary="دریافت heatmap رطوبت خاک مزرعه", + description=( + "با دریافت farm_uuid، heatmap رطوبت خاک را با وزن دهی زمانی/فضایی، " + "mask مرز مزرعه و برآورد عدم قطعیت از app مستقل soile برمی گرداند." + ), + request=SoilMoistureHeatmapRequestSerializer, + responses={ + 200: build_response( + SoileHeatmapEnvelopeSerializer, + "داده heatmap رطوبت خاک مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + SoileErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoileErrorSerializer, + "مزرعه یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست soile", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilMoistureHeatmapRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("soile").get_soil_moisture_service() + try: + data = service.get_heatmap(farm_uuid=str(serializer.validated_data["farm_uuid"])) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class SoilHealthSummaryView(APIView): + @extend_schema( + tags=["Soile"], + summary="خلاصه سلامت و رطوبت خاک مزرعه", + description="با دریافت farm_uuid، امتیاز سلامت خاک/سنسور و میانگین رطوبت فعلی خاک را برمی گرداند.", + request=SoilHealthSummaryRequestSerializer, + responses={ + 200: build_response(SoileHealthEnvelopeSerializer, "خلاصه سلامت خاک با موفقیت بازگردانده شد."), + 400: build_response(SoileErrorSerializer, "داده ورودی نامعتبر است."), + 404: build_response(SoileErrorSerializer, "مزرعه یافت نشد."), + }, + examples=[ + OpenApiExample( + "نمونه درخواست soil health", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilHealthSummaryRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("soile").get_soil_health_service() + try: + data = service.get_health_summary(farm_uuid=str(serializer.validated_data["farm_uuid"])) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class SoilAnomalyDetectionView(APIView): + @extend_schema( + tags=["Soile"], + summary="تحلیل ناهنجاری خاک با کمک RAG", + description="با دریافت farm_uuid، ناهنجاری های آماری داده های خاک را استخراج می کند و تفسیر تخصصی آن را با پایگاه دانش و tone مستقل برمی گرداند.", + request=SoilAnomalyDetectionRequestSerializer, + responses={ + 200: build_response( + SoileAnomalyEnvelopeSerializer, + "خروجی تحلیل ناهنجاری خاک با موفقیت بازگردانده شد.", + ), + 400: build_response( + SoileErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + SoileErrorSerializer, + "مزرعه یافت نشد.", + ), + 500: build_response( + SoileErrorSerializer, + "خطا در تحلیل ناهنجاری خاک.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست anomaly", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = SoilAnomalyDetectionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("soile").get_soil_anomaly_service() + try: + data = service.get_anomaly_detection( + farm_uuid=str(serializer.validated_data["farm_uuid"]) + ) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تحلیل ناهنجاری خاک: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/weather/__init__.py b/Modules/Ai/weather/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/weather/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/weather/adapters.py b/Modules/Ai/weather/adapters.py new file mode 100644 index 0000000..56ae4b0 --- /dev/null +++ b/Modules/Ai/weather/adapters.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import hashlib +import math +import random +import time +from abc import ABC, abstractmethod +from datetime import date, timedelta + +try: + import requests +except ImportError: # pragma: no cover - handled when live adapter is used + requests = None + + +DEFAULT_FORECAST_DAYS = 7 +DAILY_FIELDS = [ + "temperature_2m_max", + "temperature_2m_min", + "temperature_2m_mean", + "precipitation_sum", + "precipitation_probability_max", + "relative_humidity_2m_mean", + "wind_speed_10m_max", + "et0_fao_evapotranspiration", + "weather_code", +] +WMO_CODES = [0, 1, 2, 3, 45, 51, 61, 63, 65, 80, 95] + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(upper, value)) + + +class BaseWeatherAdapter(ABC): + source_name = "base" + + @abstractmethod + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + """Return daily forecast data in Open-Meteo compatible shape.""" + + +class OpenMeteoWeatherAdapter(BaseWeatherAdapter): + source_name = "open-meteo" + + def __init__(self, base_url: str, api_key: str = "", timeout: float = 60): + self.base_url = base_url + self.api_key = api_key + self.timeout = timeout + + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + if requests is None: + raise RuntimeError("requests package is required for OpenMeteoWeatherAdapter") + + params = { + "latitude": latitude, + "longitude": longitude, + "forecast_days": days, + "timezone": "auto", + "daily": DAILY_FIELDS, + } + headers = {"accept": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + response = requests.get( + self.base_url, + params=params, + headers=headers, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + + +class MockWeatherAdapter(BaseWeatherAdapter): + source_name = "mock" + + def __init__( + self, + delay_seconds: float = 0.8, + seed_namespace: str = "croplogic-weather", + ): + self.delay_seconds = max(0.0, delay_seconds) + self.seed_namespace = seed_namespace + + def fetch_forecast(self, latitude: float, longitude: float, days: int = DEFAULT_FORECAST_DAYS) -> dict: + if self.delay_seconds: + time.sleep(self.delay_seconds) + + climate = self._layered_noise(latitude, longitude, "climate") + humidity_bias = self._layered_noise(latitude, longitude, "humidity") + rain_bias = self._layered_noise(latitude, longitude, "rain") + wind_bias = self._layered_noise(latitude, longitude, "wind") + temp_bias = self._layered_noise(latitude, longitude, "temp") + + start = date.today() + payload = {field: [] for field in DAILY_FIELDS} + payload["time"] = [] + + for day_index in range(days): + current_date = start + timedelta(days=day_index) + seasonal_wave = math.sin(((current_date.timetuple().tm_yday / 365.0) * math.tau) - 0.55) + daily_wave = math.sin((day_index / max(days, 1)) * math.tau) + short_term = self._layered_noise( + latitude + (day_index * 0.11), + longitude - (day_index * 0.09), + f"day:{day_index}", + ) + + temp_mean = _clamp( + 17.0 + + (seasonal_wave * 11.0) + + ((temp_bias - 0.5) * 8.0) + + (daily_wave * 2.8) + + ((short_term - 0.5) * 2.5), + -6.0, + 43.0, + ) + diurnal_range = _clamp( + 8.0 + ((1 - humidity_bias) * 4.2) + ((1 - rain_bias) * 2.0) + (short_term * 1.1), + 5.0, + 16.0, + ) + temperature_max = _clamp(temp_mean + (diurnal_range / 2.0), -3.0, 48.0) + temperature_min = _clamp(temp_mean - (diurnal_range / 2.0), -12.0, 35.0) + + humidity_mean = _clamp( + 34.0 + + (humidity_bias * 34.0) + + (rain_bias * 12.0) + - ((temperature_max - 22.0) * 0.9), + 18.0, + 96.0, + ) + precipitation_probability = _clamp( + 10.0 + + (rain_bias * 45.0) + + ((humidity_mean - 45.0) * 0.45) + + (max(0.0, 0.5 - temp_bias) * 18.0) + + ((short_term - 0.5) * 18.0), + 0.0, + 100.0, + ) + precipitation = self._precipitation_amount( + precipitation_probability=precipitation_probability, + rain_bias=rain_bias, + humidity_mean=humidity_mean, + short_term=short_term, + ) + wind_speed = _clamp( + 8.0 + + (wind_bias * 17.0) + + ((1 - rain_bias) * 2.5) + + (abs(daily_wave) * 3.0) + + (short_term * 2.0), + 3.0, + 42.0, + ) + et0 = _clamp( + 1.0 + + (max(temp_mean, 0.0) * 0.11) + + ((1 - (humidity_mean / 100.0)) * 1.7) + + (wind_speed * 0.03) + - (precipitation * 0.05), + 0.3, + 11.0, + ) + weather_code = self._weather_code( + precipitation=precipitation, + probability=precipitation_probability, + humidity=humidity_mean, + wind_speed=wind_speed, + cloudiness=(humidity_bias + rain_bias + (1 - temp_bias)) / 3.0, + ) + + payload["time"].append(current_date.isoformat()) + payload["temperature_2m_max"].append(round(temperature_max, 1)) + payload["temperature_2m_min"].append(round(temperature_min, 1)) + payload["temperature_2m_mean"].append(round(temp_mean, 1)) + payload["precipitation_sum"].append(round(precipitation, 1)) + payload["precipitation_probability_max"].append(round(precipitation_probability, 0)) + payload["relative_humidity_2m_mean"].append(round(humidity_mean, 1)) + payload["wind_speed_10m_max"].append(round(wind_speed, 1)) + payload["et0_fao_evapotranspiration"].append(round(et0, 2)) + payload["weather_code"].append(weather_code) + + return {"latitude": latitude, "longitude": longitude, "daily": payload} + + def _precipitation_amount( + self, + precipitation_probability: float, + rain_bias: float, + humidity_mean: float, + short_term: float, + ) -> float: + trigger = precipitation_probability / 100.0 + if trigger < 0.24: + return 0.0 + + amount = ( + ((trigger - 0.2) ** 1.55) * 18.0 + + (rain_bias * 1.6) + + ((humidity_mean - 50.0) * 0.035) + + (short_term * 1.3) + ) + return _clamp(amount, 0.0, 34.0) + + def _weather_code( + self, + precipitation: float, + probability: float, + humidity: float, + wind_speed: float, + cloudiness: float, + ) -> int: + if precipitation >= 10: + return 65 + if precipitation >= 4: + return 63 + if precipitation > 0.6: + return 61 + if probability >= 65 and humidity >= 70: + return 51 + if cloudiness >= 0.8: + return 3 + if cloudiness >= 0.62: + return 2 + if cloudiness >= 0.48 or wind_speed >= 28: + return 1 + return 0 + + def _layered_noise(self, latitude: float, longitude: float, key: str) -> float: + regional = self._smooth_noise(latitude, longitude, f"{key}:regional", scale=2.4) + local = self._smooth_noise(latitude, longitude, f"{key}:local", scale=0.45) + micro = self._smooth_noise(latitude, longitude, f"{key}:micro", scale=0.12) + return _clamp((regional * 0.58) + (local * 0.27) + (micro * 0.15), 0.0, 1.0) + + def _smooth_noise(self, latitude: float, longitude: float, key: str, scale: float) -> float: + grid_x = longitude / scale + grid_y = latitude / scale + x0 = math.floor(grid_x) + y0 = math.floor(grid_y) + tx = grid_x - x0 + ty = grid_y - y0 + + v00 = self._cell_noise(key, x0, y0) + v10 = self._cell_noise(key, x0 + 1, y0) + v01 = self._cell_noise(key, x0, y0 + 1) + v11 = self._cell_noise(key, x0 + 1, y0 + 1) + + tx = tx * tx * (3.0 - (2.0 * tx)) + ty = ty * ty * (3.0 - (2.0 * ty)) + top = (v00 * (1 - tx)) + (v10 * tx) + bottom = (v01 * (1 - tx)) + (v11 * tx) + return (top * (1 - ty)) + (bottom * ty) + + def _cell_noise(self, key: str, grid_x: int, grid_y: int) -> float: + seed_input = f"{self.seed_namespace}:{key}:{grid_x}:{grid_y}" + digest = hashlib.sha256(seed_input.encode("ascii")).digest() + seed = int.from_bytes(digest[:8], "big", signed=False) + return random.Random(seed).random() + + +def get_weather_adapter() -> BaseWeatherAdapter: + from django.conf import settings + + provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo") + if provider == "open-meteo": + return OpenMeteoWeatherAdapter( + base_url=settings.WEATHER_API_BASE_URL, + api_key=settings.WEATHER_API_KEY, + timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60), + ) + if provider == "mock": + if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)): + raise RuntimeError("Mock weather provider is disabled outside dev/test environments.") + return MockWeatherAdapter( + delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8) + ) + raise ValueError(f"Unsupported weather data provider: {provider}") diff --git a/Modules/Ai/weather/admin.py b/Modules/Ai/weather/admin.py new file mode 100644 index 0000000..a3fce71 --- /dev/null +++ b/Modules/Ai/weather/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from .models import WeatherForecast, WeatherParameter + + +@admin.register(WeatherParameter) +class WeatherParameterAdmin(admin.ModelAdmin): + list_display = ("code", "name_fa", "unit", "created_at") + search_fields = ("code", "name_fa") + + +@admin.register(WeatherForecast) +class WeatherForecastAdmin(admin.ModelAdmin): + list_display = ( + "location", + "forecast_date", + "temperature_min", + "temperature_max", + "precipitation", + "et0", + "fetched_at", + ) + list_filter = ("forecast_date",) + search_fields = ("location__latitude", "location__longitude") diff --git a/Modules/Ai/weather/apps.py b/Modules/Ai/weather/apps.py new file mode 100644 index 0000000..9eb8725 --- /dev/null +++ b/Modules/Ai/weather/apps.py @@ -0,0 +1,36 @@ +from functools import cached_property + +from django.apps import AppConfig + + +class WeatherConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "weather" + verbose_name = "Weather Forecast" + + @cached_property + def farm_weather_service(self): + from .farm_weather import FarmWeatherService + + return FarmWeatherService() + + def get_farm_weather_service(self): + return self.farm_weather_service + + @cached_property + def water_need_service(self): + from .water_need_prediction import WaterNeedPredictionService + + return WaterNeedPredictionService() + + def get_water_need_service(self): + return self.water_need_service + + @cached_property + def weather_data_adapter(self): + from .adapters import get_weather_adapter + + return get_weather_adapter() + + def get_weather_data_adapter(self): + return self.weather_data_adapter diff --git a/Modules/Ai/weather/farm_weather.py b/Modules/Ai/weather/farm_weather.py new file mode 100644 index 0000000..953b9ce --- /dev/null +++ b/Modules/Ai/weather/farm_weather.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any + +from farm_data.models import SensorData + +from .services import get_forecast_for_location + + +WMO_CONDITIONS = { + 0: "صاف", + 1: "عمدتاً صاف", + 2: "نیمه‌ابری", + 3: "ابری", + 45: "مه", + 48: "مه یخ‌زده", + 51: "نم‌نم باران", + 61: "بارش خفیف", + 63: "بارش متوسط", + 65: "بارش شدید", + 71: "برف خفیف", + 80: "رگبار", + 95: "رعد و برق", +} + + +def _safe_number(value, default=0): + return default if value is None else value + + +def _average(values, default=0): + clean_values = [value for value in values if value is not None] + if not clean_values: + return default + return sum(clean_values) / len(clean_values) + + +def _weather_condition(weather_code): + return WMO_CONDITIONS.get(weather_code, "نامشخص") + + +def _build_farm_weather_card(forecasts: list[Any]) -> dict[str, Any]: + if not forecasts: + return { + "condition": "نامشخص", + "temperature": 0, + "unit": "°C", + "humidity": 0, + "windSpeed": 0, + "windUnit": "km/h", + "chartData": {"labels": [], "series": [[]]}, + } + + current_forecast = forecasts[0] + labels = [str(forecast.forecast_date) for forecast in forecasts[:7]] + series = [[round(_safe_number(forecast.temperature_mean, 0)) for forecast in forecasts[:7]]] + + return { + "condition": _weather_condition(current_forecast.weather_code), + "temperature": round(_safe_number(current_forecast.temperature_mean, current_forecast.temperature_max)), + "unit": "°C", + "humidity": round(_average([current_forecast.humidity_mean], default=0)), + "windSpeed": round(_safe_number(current_forecast.wind_speed_max, 0)), + "windUnit": "km/h", + "chartData": { + "labels": labels, + "series": series, + }, + } + + +class FarmWeatherService: + def get_farm_weather_card(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + + forecasts = get_forecast_for_location(sensor.center_location, days=7) + return _build_farm_weather_card(forecasts) diff --git a/Modules/Ai/weather/management/__init__.py b/Modules/Ai/weather/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/weather/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/weather/management/commands/__init__.py b/Modules/Ai/weather/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/weather/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/weather/management/commands/seed_weather_data.py b/Modules/Ai/weather/management/commands/seed_weather_data.py new file mode 100644 index 0000000..14eda1e --- /dev/null +++ b/Modules/Ai/weather/management/commands/seed_weather_data.py @@ -0,0 +1,89 @@ +""" +Management command to seed fixed weather forecasts for the demo farm location. +Run: python manage.py seed_weather_data +""" +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from location_data.models import SoilLocation +from weather.models import WeatherForecast + + +DEMO_LATITUDE = "50.000000" +DEMO_LONGITUDE = "50.000000" +DEMO_FORECASTS = [ + { + "day_offset": 0, + "temperature_min": 14.0, + "temperature_max": 24.5, + "temperature_mean": 19.3, + "precipitation": 0.0, + "precipitation_probability": 5.0, + "humidity_mean": 48.0, + "wind_speed_max": 12.0, + "et0": 4.2, + "weather_code": 1, + }, + { + "day_offset": 1, + "temperature_min": 13.5, + "temperature_max": 22.0, + "temperature_mean": 17.8, + "precipitation": 2.4, + "precipitation_probability": 60.0, + "humidity_mean": 61.0, + "wind_speed_max": 18.0, + "et0": 3.7, + "weather_code": 61, + }, + { + "day_offset": 2, + "temperature_min": 12.8, + "temperature_max": 20.5, + "temperature_mean": 16.4, + "precipitation": 4.8, + "precipitation_probability": 78.0, + "humidity_mean": 68.0, + "wind_speed_max": 20.0, + "et0": 3.1, + "weather_code": 63, + }, +] + + +class Command(BaseCommand): + help = "Seed weather forecast rows for the fixed 50.00, 50.00 demo location." + + def handle(self, *args, **options): + location, _ = SoilLocation.objects.get_or_create( + latitude=DEMO_LATITUDE, + longitude=DEMO_LONGITUDE, + ) + today = timezone.now().date() + + self.stdout.write( + self.style.SUCCESS( + f"Using SoilLocation id={location.id} at ({location.latitude}, {location.longitude})" + ) + ) + + for item in DEMO_FORECASTS: + forecast_date = today + timedelta(days=item["day_offset"]) + defaults = { + key: value + for key, value in item.items() + if key != "day_offset" + } + _, created = WeatherForecast.objects.update_or_create( + location=location, + forecast_date=forecast_date, + defaults=defaults, + ) + status_text = "Created" if created else "Updated" + self.stdout.write( + self.style.SUCCESS(f" {status_text} WeatherForecast for {forecast_date}") + ) + + self.stdout.write(self.style.SUCCESS("\nDone seeding weather_data demo records.")) diff --git a/Modules/Ai/weather/management/commands/seed_weather_parameters.py b/Modules/Ai/weather/management/commands/seed_weather_parameters.py new file mode 100644 index 0000000..a08062c --- /dev/null +++ b/Modules/Ai/weather/management/commands/seed_weather_parameters.py @@ -0,0 +1,42 @@ +""" +Management command to seed weather parameters. +Run: python manage.py seed_weather_parameters +""" +from django.core.management.base import BaseCommand + +from weather.models import WeatherParameter + + +INITIAL_PARAMETERS = [ + ("temperature_min", "حداقل دمای هوا", "°C"), + ("temperature_max", "حداکثر دمای هوا", "°C"), + ("temperature_mean", "میانگین دمای هوا", "°C"), + ("precipitation", "مجموع بارش", "mm"), + ("precipitation_probability", "احتمال بارش", "%"), + ("humidity_mean", "میانگین رطوبت نسبی", "%"), + ("wind_speed_max", "حداکثر سرعت باد", "km/h"), + ("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"), + ("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""), +] + + +class Command(BaseCommand): + help = "Seed weather parameters (temperature, precipitation, ET0, etc.)" + + def handle(self, *args, **options): + created_count = 0 + for code, name_fa, unit in INITIAL_PARAMETERS: + _, created = WeatherParameter.objects.get_or_create( + code=code, + defaults={"name_fa": name_fa, "unit": unit}, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {code} ({name_fa})") + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new weather parameters." + ) + ) diff --git a/Modules/Ai/weather/migrations/0001_initial.py b/Modules/Ai/weather/migrations/0001_initial.py new file mode 100644 index 0000000..0b955da --- /dev/null +++ b/Modules/Ai/weather/migrations/0001_initial.py @@ -0,0 +1,184 @@ +# Generated manually for weather + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [ + # ── WeatherParameter ── + migrations.CreateModel( + name="WeatherParameter", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + db_index=True, + help_text="کد یکتا (مثلاً temperature_max)", + max_length=64, + unique=True, + ), + ), + ( + "name_fa", + models.CharField( + help_text="نام فارسی", + max_length=128, + ), + ), + ( + "unit", + models.CharField( + blank=True, + help_text="واحد اندازه‌گیری", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["code"], + "verbose_name": "پارامتر هواشناسی", + "verbose_name_plural": "پارامترهای هواشناسی", + }, + ), + # ── WeatherForecast ── + migrations.CreateModel( + name="WeatherForecast", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "location", + models.ForeignKey( + help_text="موقعیت مکانی مرتبط از جدول SoilLocation", + on_delete=django.db.models.deletion.CASCADE, + related_name="weather_forecasts", + to="location_data.soillocation", + ), + ), + ( + "forecast_date", + models.DateField( + db_index=True, + help_text="تاریخ پیش‌بینی", + ), + ), + ( + "temperature_min", + models.FloatField( + blank=True, + help_text="حداقل دمای هوا (°C)", + null=True, + ), + ), + ( + "temperature_max", + models.FloatField( + blank=True, + help_text="حداکثر دمای هوا (°C)", + null=True, + ), + ), + ( + "temperature_mean", + models.FloatField( + blank=True, + help_text="میانگین دمای هوا (°C)", + null=True, + ), + ), + ( + "precipitation", + models.FloatField( + blank=True, + help_text="مجموع بارش (mm)", + null=True, + ), + ), + ( + "precipitation_probability", + models.FloatField( + blank=True, + help_text="احتمال بارش (%)", + null=True, + ), + ), + ( + "humidity_mean", + models.FloatField( + blank=True, + help_text="میانگین رطوبت نسبی (%)", + null=True, + ), + ), + ( + "wind_speed_max", + models.FloatField( + blank=True, + help_text="حداکثر سرعت باد (km/h)", + null=True, + ), + ), + ( + "et0", + models.FloatField( + blank=True, + help_text="تبخیر-تعرق مرجع (ET₀) — mm/day", + null=True, + ), + ), + ( + "weather_code", + models.IntegerField( + blank=True, + help_text="کد وضعیت آب‌وهوا (WMO code)", + null=True, + ), + ), + ( + "fetched_at", + models.DateTimeField( + auto_now=True, + help_text="آخرین زمان واکشی از API", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["location", "forecast_date"], + "verbose_name": "پیش‌بینی هواشناسی", + "verbose_name_plural": "پیش‌بینی‌های هواشناسی", + }, + ), + migrations.AddConstraint( + model_name="weatherforecast", + constraint=models.UniqueConstraint( + fields=("location", "forecast_date"), + name="weather_unique_location_date", + ), + ), + ] diff --git a/Modules/Ai/weather/migrations/0002_seed_weather_parameters.py b/Modules/Ai/weather/migrations/0002_seed_weather_parameters.py new file mode 100644 index 0000000..9a2b1fe --- /dev/null +++ b/Modules/Ai/weather/migrations/0002_seed_weather_parameters.py @@ -0,0 +1,42 @@ +# Seed migration: populate initial weather parameters. + +from django.db import migrations + + +INITIAL_PARAMETERS = [ + ("temperature_min", "حداقل دمای هوا", "°C"), + ("temperature_max", "حداکثر دمای هوا", "°C"), + ("temperature_mean", "میانگین دمای هوا", "°C"), + ("precipitation", "مجموع بارش", "mm"), + ("precipitation_probability", "احتمال بارش", "%"), + ("humidity_mean", "میانگین رطوبت نسبی", "%"), + ("wind_speed_max", "حداکثر سرعت باد", "km/h"), + ("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"), + ("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""), +] + + +def seed_parameters(apps, schema_editor): + WeatherParameter = apps.get_model("weather", "WeatherParameter") + for code, name_fa, unit in INITIAL_PARAMETERS: + WeatherParameter.objects.get_or_create( + code=code, + defaults={"name_fa": name_fa, "unit": unit}, + ) + + +def unseed_parameters(apps, schema_editor): + WeatherParameter = apps.get_model("weather", "WeatherParameter") + codes = [p[0] for p in INITIAL_PARAMETERS] + WeatherParameter.objects.filter(code__in=codes).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("weather", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_parameters, unseed_parameters), + ] diff --git a/Modules/Ai/weather/migrations/0003_seed_weather_forecasts.py b/Modules/Ai/weather/migrations/0003_seed_weather_forecasts.py new file mode 100644 index 0000000..2353b95 --- /dev/null +++ b/Modules/Ai/weather/migrations/0003_seed_weather_forecasts.py @@ -0,0 +1,137 @@ +# Seed migration: populate sample 7-day weather forecasts for existing SoilLocations. + +from datetime import timedelta + +from django.db import migrations +from django.utils import timezone + + +SAMPLE_DAILY_DATA = [ + { + "day_offset": 0, + "temperature_min": 18.5, + "temperature_max": 33.2, + "temperature_mean": 25.8, + "precipitation": 0.0, + "precipitation_probability": 5.0, + "humidity_mean": 28.0, + "wind_speed_max": 12.0, + "et0": 6.8, + "weather_code": 0, + }, + { + "day_offset": 1, + "temperature_min": 19.0, + "temperature_max": 34.5, + "temperature_mean": 26.7, + "precipitation": 0.0, + "precipitation_probability": 10.0, + "humidity_mean": 30.0, + "wind_speed_max": 14.0, + "et0": 7.1, + "weather_code": 1, + }, + { + "day_offset": 2, + "temperature_min": 20.2, + "temperature_max": 32.0, + "temperature_mean": 26.1, + "precipitation": 3.5, + "precipitation_probability": 65.0, + "humidity_mean": 52.0, + "wind_speed_max": 18.0, + "et0": 5.2, + "weather_code": 61, + }, + { + "day_offset": 3, + "temperature_min": 17.8, + "temperature_max": 28.5, + "temperature_mean": 23.1, + "precipitation": 12.0, + "precipitation_probability": 85.0, + "humidity_mean": 70.0, + "wind_speed_max": 22.0, + "et0": 3.8, + "weather_code": 63, + }, + { + "day_offset": 4, + "temperature_min": 16.5, + "temperature_max": 27.0, + "temperature_mean": 21.7, + "precipitation": 5.0, + "precipitation_probability": 55.0, + "humidity_mean": 60.0, + "wind_speed_max": 16.0, + "et0": 4.5, + "weather_code": 61, + }, + { + "day_offset": 5, + "temperature_min": 18.0, + "temperature_max": 31.0, + "temperature_mean": 24.5, + "precipitation": 0.0, + "precipitation_probability": 8.0, + "humidity_mean": 35.0, + "wind_speed_max": 10.0, + "et0": 6.2, + "weather_code": 2, + }, + { + "day_offset": 6, + "temperature_min": 19.5, + "temperature_max": 34.0, + "temperature_mean": 26.7, + "precipitation": 0.0, + "precipitation_probability": 3.0, + "humidity_mean": 25.0, + "wind_speed_max": 8.0, + "et0": 7.0, + "weather_code": 0, + }, +] + + +def seed_forecasts(apps, schema_editor): + SoilLocation = apps.get_model("location_data", "SoilLocation") + WeatherForecast = apps.get_model("weather", "WeatherForecast") + + today = timezone.now().date() + + for location in SoilLocation.objects.all(): + for daily in SAMPLE_DAILY_DATA: + forecast_date = today + timedelta(days=daily["day_offset"]) + WeatherForecast.objects.get_or_create( + location=location, + forecast_date=forecast_date, + defaults={ + "temperature_min": daily["temperature_min"], + "temperature_max": daily["temperature_max"], + "temperature_mean": daily["temperature_mean"], + "precipitation": daily["precipitation"], + "precipitation_probability": daily["precipitation_probability"], + "humidity_mean": daily["humidity_mean"], + "wind_speed_max": daily["wind_speed_max"], + "et0": daily["et0"], + "weather_code": daily["weather_code"], + }, + ) + + +def unseed_forecasts(apps, schema_editor): + WeatherForecast = apps.get_model("weather", "WeatherForecast") + WeatherForecast.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("weather", "0002_seed_weather_parameters"), + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [ + migrations.RunPython(seed_forecasts, unseed_forecasts), + ] diff --git a/Modules/Ai/weather/migrations/__init__.py b/Modules/Ai/weather/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Ai/weather/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Ai/weather/models.py b/Modules/Ai/weather/models.py new file mode 100644 index 0000000..dcf09d0 --- /dev/null +++ b/Modules/Ai/weather/models.py @@ -0,0 +1,107 @@ +from django.db import models + + +class WeatherParameter(models.Model): + """ + تعریف پارامترهای هواشناسی (مثلاً دما، بارش، تبخیر-تعرق، ...). + """ + + code = models.CharField( + max_length=64, + unique=True, + db_index=True, + help_text="کد یکتا (مثلاً temperature_max)", + ) + name_fa = models.CharField(max_length=128, help_text="نام فارسی") + unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["code"] + verbose_name = "پارامتر هواشناسی" + verbose_name_plural = "پارامترهای هواشناسی" + + def __str__(self): + return f"{self.code} ({self.name_fa})" + + +class WeatherForecast(models.Model): + """ + پیش‌بینی هواشناسی روزانه (تا ۷ روز آینده) برای یک SoilLocation. + داده‌ها شامل دما، بارش، رطوبت، باد و تبخیر-تعرق مرجع (ET0) هستند. + """ + + location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="weather_forecasts", + help_text="موقعیت مکانی مرتبط از جدول SoilLocation", + ) + forecast_date = models.DateField( + db_index=True, + help_text="تاریخ پیش‌بینی", + ) + + temperature_min = models.FloatField( + null=True, blank=True, help_text="حداقل دمای هوا (°C)" + ) + temperature_max = models.FloatField( + null=True, blank=True, help_text="حداکثر دمای هوا (°C)" + ) + temperature_mean = models.FloatField( + null=True, blank=True, help_text="میانگین دمای هوا (°C)" + ) + + precipitation = models.FloatField( + null=True, blank=True, help_text="مجموع بارش (mm)" + ) + precipitation_probability = models.FloatField( + null=True, blank=True, help_text="احتمال بارش (%)" + ) + + humidity_mean = models.FloatField( + null=True, blank=True, help_text="میانگین رطوبت نسبی (%)" + ) + + wind_speed_max = models.FloatField( + null=True, blank=True, help_text="حداکثر سرعت باد (km/h)" + ) + + et0 = models.FloatField( + null=True, + blank=True, + help_text="تبخیر-تعرق مرجع (ET₀) — mm/day", + ) + + weather_code = models.IntegerField( + null=True, + blank=True, + help_text="کد وضعیت آب‌وهوا (WMO code)", + ) + + fetched_at = models.DateTimeField( + auto_now=True, + help_text="آخرین زمان واکشی از API", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["location", "forecast_date"], + name="weather_unique_location_date", + ) + ] + ordering = ["location", "forecast_date"] + verbose_name = "پیش‌بینی هواشناسی" + verbose_name_plural = "پیش‌بینی‌های هواشناسی" + + def __str__(self): + return f"WeatherForecast({self.location_id}, {self.forecast_date})" + + @property + def will_rain(self): + """آیا بارندگی پیش‌بینی شده است؟""" + if self.precipitation is not None: + return self.precipitation > 0.0 + return None diff --git a/Modules/Ai/weather/serializers.py b/Modules/Ai/weather/serializers.py new file mode 100644 index 0000000..a31d0d9 --- /dev/null +++ b/Modules/Ai/weather/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers + + +class FarmWeatherRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class WeatherChartDataSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField()) + series = serializers.ListField(child=serializers.ListField(child=serializers.FloatField())) + + +class FarmWeatherResponseSerializer(serializers.Serializer): + condition = serializers.CharField() + temperature = serializers.FloatField() + unit = serializers.CharField() + humidity = serializers.FloatField() + windSpeed = serializers.FloatField() + windUnit = serializers.CharField() + chartData = WeatherChartDataSerializer() + + +class WaterNeedPredictionRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه") + + +class WaterNeedPredictionResponseSerializer(serializers.Serializer): + farm_uuid = serializers.CharField() + totalNext7Days = serializers.FloatField() + unit = serializers.CharField() + categories = serializers.ListField(child=serializers.CharField()) + series = serializers.JSONField() + dailyBreakdown = serializers.JSONField() + insight = serializers.JSONField() + raw_response = serializers.CharField(allow_null=True, required=False) diff --git a/Modules/Ai/weather/services.py b/Modules/Ai/weather/services.py new file mode 100644 index 0000000..9e85a87 --- /dev/null +++ b/Modules/Ai/weather/services.py @@ -0,0 +1,184 @@ +""" +سرویس‌های هواشناسی — واکشی پیش‌بینی ۷ روزه و ذخیره در دیتابیس. +""" + +import logging +from datetime import date, timedelta + +from django.apps import apps +from django.db import transaction + +from location_data.models import SoilLocation + +from .adapters import DEFAULT_FORECAST_DAYS +from .models import WeatherForecast + +logger = logging.getLogger(__name__) + + +def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None: + """ + واکشی پیش‌بینی هواشناسی از provider فعال. + خروجی در قالب سازگار با Open-Meteo daily format برگردانده می‌شود. + """ + adapter = apps.get_app_config("weather").get_weather_data_adapter() + return adapter.fetch_forecast( + latitude=latitude, + longitude=longitude, + days=DEFAULT_FORECAST_DAYS, + ) + + +def parse_weather_response(data: dict) -> list[dict]: + """ + تبدیل پاسخ API به لیست dict برای ذخیره در WeatherForecast. + فرمت ورودی: Open-Meteo daily format. + """ + daily = data.get("daily", {}) + times = daily.get("time", []) + forecasts = [] + + for index, date_str in enumerate(times): + forecasts.append( + { + "forecast_date": date_str, + "temperature_max": _safe_index(daily.get("temperature_2m_max"), index), + "temperature_min": _safe_index(daily.get("temperature_2m_min"), index), + "temperature_mean": _safe_index(daily.get("temperature_2m_mean"), index), + "precipitation": _safe_index(daily.get("precipitation_sum"), index), + "precipitation_probability": _safe_index( + daily.get("precipitation_probability_max"), + index, + ), + "humidity_mean": _safe_index( + daily.get("relative_humidity_2m_mean"), + index, + ), + "wind_speed_max": _safe_index(daily.get("wind_speed_10m_max"), index), + "et0": _safe_index( + daily.get("et0_fao_evapotranspiration"), + index, + ), + "weather_code": _safe_index(daily.get("weather_code"), index), + } + ) + return forecasts + + +def _safe_index(lst: list | None, index: int): + """مقدار index را از لیست برمی‌گرداند یا None.""" + if lst is None or index >= len(lst): + return None + return lst[index] + + +def update_weather_for_location(location: SoilLocation) -> dict: + """ + واکشی و ذخیره پیش‌بینی هواشناسی ۷ روزه برای یک SoilLocation. + + خروجی: + {"status": "success"|"no_data"|"error", "location_id": int, ...} + """ + lat = float(location.latitude) + lon = float(location.longitude) + + try: + data = fetch_weather_from_api(lat, lon) + except Exception as exc: + logger.error("Weather API error for location %s: %s", location.id, exc) + return { + "status": "error", + "location_id": location.id, + "error": str(exc), + } + + if data is None: + logger.info("Weather provider returned no data for location %s.", location.id) + return { + "status": "no_data", + "location_id": location.id, + "message": "Weather provider returned no data.", + } + + forecasts = parse_weather_response(data) + + with transaction.atomic(): + for forecast in forecasts: + WeatherForecast.objects.update_or_create( + location=location, + forecast_date=forecast.pop("forecast_date"), + defaults=forecast, + ) + + return { + "status": "success", + "location_id": location.id, + "days_updated": len(forecasts), + } + + +def update_weather_for_all_locations() -> list[dict]: + """ + واکشی پیش‌بینی هواشناسی برای تمام SoilLocation‌های موجود. + """ + results = [] + for location in SoilLocation.objects.all(): + results.append(update_weather_for_location(location)) + return results + + +def get_forecast_for_location( + location: SoilLocation, + days: int = DEFAULT_FORECAST_DAYS, +) -> list[WeatherForecast]: + """ + دریافت پیش‌بینی‌های ذخیره‌شده برای یک location (تا N روز آینده). + """ + today = date.today() + end_date = today + timedelta(days=days) + return list( + WeatherForecast.objects.filter( + location=location, + forecast_date__gte=today, + forecast_date__lte=end_date, + ).order_by("forecast_date") + ) + + +def should_irrigate_today(location: SoilLocation) -> dict: + """ + بررسی ساده: آیا فردا باران می‌بارد؟ + اگر بارش فردا بیشتر از آستانه باشد → آبیاری لازم نیست. + """ + tomorrow = date.today() + timedelta(days=1) + forecast = WeatherForecast.objects.filter( + location=location, + forecast_date=tomorrow, + ).first() + + if forecast is None: + return { + "needs_irrigation": None, + "tomorrow_precipitation": None, + "tomorrow_date": str(tomorrow), + "reason": "داده پیش‌بینی فردا موجود نیست.", + } + + rain_threshold_mm = 2.0 + if forecast.precipitation is not None and forecast.precipitation >= rain_threshold_mm: + return { + "needs_irrigation": False, + "tomorrow_precipitation": forecast.precipitation, + "tomorrow_date": str(tomorrow), + "reason": ( + f"فردا {forecast.precipitation} mm بارش پیش‌بینی شده — " + "نیاز به آبیاری نیست." + ), + } + + return { + "needs_irrigation": True, + "tomorrow_precipitation": forecast.precipitation, + "tomorrow_date": str(tomorrow), + "reason": "بارش فردا ناچیز یا صفر — آبیاری توصیه می‌شود.", + } diff --git a/Modules/Ai/weather/tasks.py b/Modules/Ai/weather/tasks.py new file mode 100644 index 0000000..59c002e --- /dev/null +++ b/Modules/Ai/weather/tasks.py @@ -0,0 +1,34 @@ +""" +تسک‌های Celery برای واکشی داده‌های هواشناسی. +""" + +from config.celery import app + +from location_data.models import SoilLocation + +from .services import update_weather_for_location, update_weather_for_all_locations + + +@app.task(bind=True) +def fetch_weather_task(self, location_id: int): + """ + واکشی پیش‌بینی هواشناسی ۷ روزه برای یک location مشخص. + """ + try: + location = SoilLocation.objects.get(pk=location_id) + except SoilLocation.DoesNotExist: + return { + "status": "error", + "error": f"SoilLocation with id={location_id} not found.", + } + + return update_weather_for_location(location) + + +@app.task(bind=True) +def fetch_weather_all_locations_task(self): + """ + واکشی پیش‌بینی هواشناسی برای تمام location‌ها. + مناسب برای Celery Beat (مثلاً هر ۶ ساعت). + """ + return update_weather_for_all_locations() diff --git a/Modules/Ai/weather/test_adapters.py b/Modules/Ai/weather/test_adapters.py new file mode 100644 index 0000000..85a0036 --- /dev/null +++ b/Modules/Ai/weather/test_adapters.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from django.apps import apps +from django.test import SimpleTestCase, TestCase, override_settings + +from location_data.models import SoilLocation +from weather.adapters import MockWeatherAdapter, OpenMeteoWeatherAdapter +from weather.models import WeatherForecast +from weather.services import fetch_weather_from_api, update_weather_for_location + + +class MockWeatherAdapterTests(SimpleTestCase): + def setUp(self): + self.adapter = MockWeatherAdapter(delay_seconds=0) + + def test_same_coordinate_returns_same_forecast(self): + first = self.adapter.fetch_forecast(35.71, 51.4) + second = self.adapter.fetch_forecast(35.71, 51.4) + + self.assertEqual(first, second) + + def test_nearby_coordinates_produce_nearby_forecast(self): + first = self.adapter.fetch_forecast(35.71, 51.4) + second = self.adapter.fetch_forecast(35.715, 51.405) + + first_daily = first["daily"] + second_daily = second["daily"] + self.assertLess( + abs(first_daily["temperature_2m_mean"][0] - second_daily["temperature_2m_mean"][0]), + 2.5, + ) + self.assertLess( + abs(first_daily["relative_humidity_2m_mean"][0] - second_daily["relative_humidity_2m_mean"][0]), + 8.0, + ) + self.assertLess( + abs(first_daily["wind_speed_10m_max"][0] - second_daily["wind_speed_10m_max"][0]), + 6.0, + ) + + def test_shape_matches_open_meteo_daily_contract(self): + forecast = self.adapter.fetch_forecast(35.71, 51.4) + daily = forecast["daily"] + + self.assertEqual(len(daily["time"]), 7) + self.assertEqual(len(daily["temperature_2m_max"]), 7) + self.assertEqual(len(daily["weather_code"]), 7) + + +class WeatherAdapterSelectionTests(SimpleTestCase): + def tearDown(self): + apps.get_app_config("weather").__dict__.pop("weather_data_adapter", None) + + @override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0) + def test_app_config_returns_mock_adapter(self): + config = apps.get_app_config("weather") + config.__dict__.pop("weather_data_adapter", None) + + adapter = config.get_weather_data_adapter() + + self.assertIsInstance(adapter, MockWeatherAdapter) + + @override_settings(WEATHER_DATA_PROVIDER="open-meteo", WEATHER_TIMEOUT_SECONDS=12) + def test_app_config_returns_live_adapter(self): + config = apps.get_app_config("weather") + config.__dict__.pop("weather_data_adapter", None) + + adapter = config.get_weather_data_adapter() + + self.assertIsInstance(adapter, OpenMeteoWeatherAdapter) + self.assertEqual(adapter.timeout, 12) + + +@override_settings(WEATHER_DATA_PROVIDER="mock", WEATHER_MOCK_DELAY_SECONDS=0) +class WeatherServiceTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create( + latitude="35.710000", + longitude="51.400000", + ) + + def test_fetch_weather_from_api_uses_mock_provider(self): + payload = fetch_weather_from_api(35.71, 51.4) + + self.assertIn("daily", payload) + self.assertEqual(len(payload["daily"]["time"]), 7) + + def test_update_weather_for_location_persists_seven_days(self): + result = update_weather_for_location(self.location) + + self.assertEqual(result["status"], "success") + self.assertEqual(result["days_updated"], 7) + self.assertEqual( + WeatherForecast.objects.filter(location=self.location).count(), + 7, + ) + self.assertTrue( + WeatherForecast.objects.filter( + location=self.location, + precipitation__isnull=False, + weather_code__isnull=False, + ).exists() + ) diff --git a/Modules/Ai/weather/test_farm_weather_api.py b/Modules/Ai/weather/test_farm_weather_api.py new file mode 100644 index 0000000..bdcb262 --- /dev/null +++ b/Modules/Ai/weather/test_farm_weather_api.py @@ -0,0 +1,152 @@ +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 rag.failure_contract import RAGServiceError + + +@override_settings(ROOT_URLCONF="weather.urls") +class FarmWeatherApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("weather.views.apps.get_app_config") + def test_farm_weather_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_farm_weather_card=lambda **_kwargs: { + "condition": "صاف", + "temperature": 28.0, + "unit": "°C", + "humidity": 42.0, + "windSpeed": 15.0, + "windUnit": "km/h", + "chartData": { + "labels": ["2026-04-01", "2026-04-02"], + "series": [[28.0, 29.0]], + }, + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_farm_weather_service=lambda: mock_service + ) + + response = self.client.post( + "/farm-card/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["condition"], "صاف") + self.assertEqual(payload["chartData"]["labels"][0], "2026-04-01") + + @patch("weather.views.apps.get_app_config") + def test_farm_weather_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_farm_weather_card=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_farm_weather_service=lambda: mock_service + ) + + response = self.client.post( + "/farm-card/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + +@override_settings(ROOT_URLCONF="weather.urls") +class WaterNeedPredictionApiTests(TestCase): + def setUp(self): + self.client = APIClient() + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_payload(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "totalNext7Days": 24.6, + "unit": "mm", + "categories": ["روز 1", "روز 2"], + "series": [{"name": "نیاز آبی تعدیل‌شده", "data": [3.2, 4.1]}], + "dailyBreakdown": [ + {"forecast_date": "2026-04-01", "gross_irrigation_mm": 3.2}, + {"forecast_date": "2026-04-02", "gross_irrigation_mm": 4.1}, + ], + "insight": { + "summary": "جمع نياز آبي هفته آينده حدود 24.6 ميلي متر است.", + "irrigation_outlook": "نياز آبي در حال افزايش است.", + "recommended_action": "آبياري صبح زود تنظيم شود.", + "risk_note": "در صورت بارش موثر برنامه بازبيني شود.", + "confidence": 0.82, + }, + "raw_response": "{\"summary\":\"ok\"}", + } + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + payload = response.json()["data"] + self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000") + self.assertEqual(payload["insight"]["confidence"], 0.82) + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_404_for_missing_farm(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw(ValueError("Farm not found.")) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["msg"], "Farm not found.") + + @patch("weather.views.apps.get_app_config") + def test_water_need_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config): + mock_service = SimpleNamespace( + get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw( + RAGServiceError( + error_code="invalid_json", + message="Water need prediction LLM response was not valid JSON.", + source="llm", + retriable=True, + http_status=502, + ) + ) + ) + mock_get_app_config.return_value = SimpleNamespace( + get_water_need_service=lambda: mock_service + ) + + response = self.client.post( + "/water-need-prediction/", + data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + format="json", + ) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.json()["data"]["error_code"], "invalid_json") diff --git a/Modules/Ai/weather/urls.py b/Modules/Ai/weather/urls.py new file mode 100644 index 0000000..c001424 --- /dev/null +++ b/Modules/Ai/weather/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import FarmWeatherCardView, WaterNeedPredictionView + + +urlpatterns = [ + path("farm-card/", FarmWeatherCardView.as_view(), name="farm-weather-card"), + path("water-need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"), +] diff --git a/Modules/Ai/weather/views.py b/Modules/Ai/weather/views.py new file mode 100644 index 0000000..c43a1ec --- /dev/null +++ b/Modules/Ai/weather/views.py @@ -0,0 +1,151 @@ +from django.apps import apps + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.openapi import build_envelope_serializer, build_response +from rag.failure_contract import RAGServiceError + +from .serializers import ( + FarmWeatherRequestSerializer, + FarmWeatherResponseSerializer, + WaterNeedPredictionRequestSerializer, + WaterNeedPredictionResponseSerializer, +) + + +FarmWeatherEnvelopeSerializer = build_envelope_serializer( + "FarmWeatherEnvelopeSerializer", + FarmWeatherResponseSerializer, +) +WeatherErrorSerializer = build_envelope_serializer( + "WeatherErrorSerializer", + data_required=False, + allow_null=True, +) +WaterNeedPredictionEnvelopeSerializer = build_envelope_serializer( + "WaterNeedPredictionEnvelopeSerializer", + WaterNeedPredictionResponseSerializer, +) + + +class FarmWeatherCardView(APIView): + @extend_schema( + tags=["Weather"], + summary="دریافت کارت آب و هوای مزرعه", + description="با دریافت farm_uuid، داده مستقل کارت آب و هوای مزرعه را از اپ weather برمی گرداند.", + request=FarmWeatherRequestSerializer, + responses={ + 200: build_response( + FarmWeatherEnvelopeSerializer, + "داده کارت آب و هوای مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + WeatherErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + WeatherErrorSerializer, + "مزرعه یافت نشد.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست weather", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = FarmWeatherRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("weather").get_farm_weather_service() + try: + data = service.get_farm_weather_card( + farm_uuid=str(serializer.validated_data["farm_uuid"]) + ) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class WaterNeedPredictionView(APIView): + @extend_schema( + tags=["Weather"], + summary="دریافت پیش بینی نیاز آبی کوتاه مدت مزرعه", + description="با دریافت farm_uuid، محاسبات نیاز آبی 7 روز آینده را از اپ weather برمی گرداند و با RAG تفسیر عملیاتی اضافه می کند.", + request=WaterNeedPredictionRequestSerializer, + responses={ + 200: build_response( + WaterNeedPredictionEnvelopeSerializer, + "داده پیش بینی نیاز آبی مزرعه با موفقیت بازگردانده شد.", + ), + 400: build_response( + WeatherErrorSerializer, + "داده ورودی نامعتبر است.", + ), + 404: build_response( + WeatherErrorSerializer, + "مزرعه یافت نشد.", + ), + 500: build_response( + WeatherErrorSerializer, + "خطا در تحلیل نیاز آبی مزرعه.", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست water need", + value={"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + request_only=True, + ) + ], + ) + def post(self, request): + serializer = WaterNeedPredictionRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + service = apps.get_app_config("weather").get_water_need_service() + try: + data = service.get_water_need_prediction( + farm_uuid=str(serializer.validated_data["farm_uuid"]) + ) + except ValueError as exc: + return Response( + {"code": 404, "msg": str(exc), "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + except RAGServiceError as exc: + return Response( + {"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()}, + status=exc.http_status, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تحلیل نیاز آبی مزرعه: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Ai/weather/water_need_prediction.py b/Modules/Ai/weather/water_need_prediction.py new file mode 100644 index 0000000..285193d --- /dev/null +++ b/Modules/Ai/weather/water_need_prediction.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from typing import Any + +from farm_data.services import clone_snapshot_as_runtime_plant, get_primary_plant_snapshot +from irrigation.evapotranspiration import calculate_forecast_water_needs, resolve_crop_profile + +from farm_data.models import SensorData +from rag.services import get_water_need_prediction_insight + +from .services import get_forecast_for_location + + +def build_water_need_prediction_payload(*, sensor: Any, forecasts: list[Any]) -> dict[str, Any]: + location = getattr(sensor, "center_location", None) + plant = clone_snapshot_as_runtime_plant(get_primary_plant_snapshot(sensor)) + irrigation_method = getattr(sensor, "irrigation_method", None) + + if not forecasts or location is None: + return { + "totalNext7Days": 0, + "unit": "mm", + "categories": [], + "series": [], + "dailyBreakdown": [], + "cropProfile": {}, + "irrigationEfficiencyPercent": None, + } + + crop_profile = resolve_crop_profile(plant) + efficiency = getattr(irrigation_method, "water_efficiency_percent", None) if irrigation_method else None + daily = calculate_forecast_water_needs( + forecasts=forecasts[:7], + latitude_deg=float(location.latitude), + crop_profile=crop_profile, + growth_stage=crop_profile.get("current_stage"), + irrigation_efficiency_percent=efficiency, + ) + daily_requirements = [round(item["gross_irrigation_mm"], 2) for item in daily] + + return { + "totalNext7Days": round(sum(daily_requirements), 2), + "unit": "mm", + "categories": [f"روز {index}" for index in range(1, len(daily_requirements) + 1)], + "series": [{"name": "نیاز آبی تعدیل‌شده", "data": daily_requirements}], + "dailyBreakdown": daily, + "cropProfile": crop_profile, + "irrigationEfficiencyPercent": efficiency, + } + + +class WaterNeedPredictionService: + def get_water_need_prediction(self, *, farm_uuid: str) -> dict[str, Any]: + sensor = ( + SensorData.objects.select_related("center_location", "irrigation_method") + .prefetch_related("plant_assignments__plant") + .filter(farm_uuid=farm_uuid) + .first() + ) + if sensor is None: + raise ValueError("Farm not found.") + + forecasts = get_forecast_for_location(sensor.center_location, days=7) + payload = build_water_need_prediction_payload(sensor=sensor, forecasts=forecasts) + insight = get_water_need_prediction_insight( + farm_uuid=farm_uuid, + prediction_payload=payload, + ) + + return { + "farm_uuid": farm_uuid, + **payload, + "insight": { + "summary": insight.get("summary"), + "irrigation_outlook": insight.get("irrigation_outlook"), + "recommended_action": insight.get("recommended_action"), + "risk_note": insight.get("risk_note"), + "confidence": insight.get("confidence"), + }, + "raw_response": insight.get("raw_response"), + } diff --git a/Modules/Backend/.cursor/postman.mdc b/Modules/Backend/.cursor/postman.mdc new file mode 100644 index 0000000..fa8049d --- /dev/null +++ b/Modules/Backend/.cursor/postman.mdc @@ -0,0 +1,74 @@ +--- +alwaysApply: false +--- +# Backend API Architecture & Postman + +## 1. URL / Routing Architecture + +- **Root (config/urls.py):** API mounts under `api//` 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. `/`), then base `""` for list. + - Example pattern: + - `path("active/", View.as_view(), kwargs={"action": "active"})` + - `path("deactive/", View.as_view(), kwargs={"action": "deactive"})` + - `path("/", 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: `/postman/.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//...` (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. `/`) 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. diff --git a/Modules/Backend/.cursor/project.mdc b/Modules/Backend/.cursor/project.mdc new file mode 100644 index 0000000..8cc1be1 --- /dev/null +++ b/Modules/Backend/.cursor/project.mdc @@ -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). diff --git a/Modules/Backend/.cursor/test-rule.mdc b/Modules/Backend/.cursor/test-rule.mdc new file mode 100644 index 0000000..6a758c2 --- /dev/null +++ b/Modules/Backend/.cursor/test-rule.mdc @@ -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. diff --git a/Modules/Backend/.dockerignore b/Modules/Backend/.dockerignore new file mode 100644 index 0000000..34fb1e8 --- /dev/null +++ b/Modules/Backend/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +.git +__pycache__ +*.pyc +.venv +venv +*.egg-info +.pytest_cache +.coverage +htmlcov +*.log +media +staticfiles +.cursor diff --git a/Modules/Backend/.env.example b/Modules/Backend/.env.example new file mode 100644 index 0000000..ad39f04 --- /dev/null +++ b/Modules/Backend/.env.example @@ -0,0 +1,45 @@ +# Django +SECRET_KEY=your-secret-key-change-in-production +DEBUG=1 +DOCKER_VERSION=develop +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,ai-web +DOCKER_VERSION=develop + +# Database (MySQL) +DB_ENGINE=django.db.backends.mysql +DB_NAME=croplogic +DB_USER=croplogic +DB_PASSWORD=changeme +DB_HOST=db +DB_PORT=3306 +DB_ROOT_PASSWORD=root + +ACCESS_CONTROL_AUTHZ_ENABLED=true +ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181 +ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision +ACCESS_CONTROL_AUTHZ_TIMEOUT=30 +# SMS.ir +SMS_IR_API_KEY= +SMS_IR_LINE_NUMBER=300000000000 +USE_EXTERNAL_API_MOCK=true + +CROP_ZONE_CHUNK_AREA_SQM=10000 + +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true + +PEST_DISEASE_RISK_SUMMARY_CACHE_TTL=14400 +WATER_NEED_PREDICTION_CACHE_TTL=14400 +SOIL_SUMMARY_CACHE_TTL=14400 +SOIL_ANOMALIES_CACHE_TTL=14400 + +FARM_ALERTS_AI_SYNC_CRON_MINUTE=0 +FARM_ALERTS_AI_SYNC_CRON_HOUR=* + +QDRANT_HOST=qdrant +QDRANT_PORT=6333 + +SENSOR_EXTERNAL_API_KEY=12345 + +SENSOR_EXTERNAL_API_KEY=12345 diff --git a/Modules/Backend/.gitea/workflows/backend.yml b/Modules/Backend/.gitea/workflows/backend.yml new file mode 100644 index 0000000..a163f8b --- /dev/null +++ b/Modules/Backend/.gitea/workflows/backend.yml @@ -0,0 +1,120 @@ +name: Backend Service CI/CD + +on: + push: + branches: [production] + paths: + - '**' + - '.gitea/workflows/backend.yml' + + pull_request: + branches: [production] + paths: + - '**' + - '.gitea/workflows/backend.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/Backend.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/Backend + git pull origin production + docker-compose -f docker-compose-prod.yaml down --remove-orphans + docker-compose -f docker-compose-prod.yaml up -d + EOF diff --git a/Modules/Backend/.github/workflows/backend.yml b/Modules/Backend/.github/workflows/backend.yml new file mode 100644 index 0000000..5dbdf91 --- /dev/null +++ b/Modules/Backend/.github/workflows/backend.yml @@ -0,0 +1,72 @@ + name: Backend Service CI/CD + + on: + push: + branches: [main] + paths: + - 'backend/**' + - 'backend/.github/workflows/backend.yml' + pull_request: + branches: [main] + paths: + - 'backend/**' + - 'backend/.github/workflows/backend.yml' + + defaults: + run: + working-directory: backend + + 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: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-backend-${{ hashFiles('backend/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-backend- + + - 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 + + deploy: + name: Deploy Backend Service + needs: test + 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/backend + git pull origin main + sudo systemctl restart backend diff --git a/Modules/Backend/.gitignore b/Modules/Backend/.gitignore new file mode 100644 index 0000000..5f9cd5a --- /dev/null +++ b/Modules/Backend/.gitignore @@ -0,0 +1,59 @@ +# 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 + +# Testing / Coverage +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ + +# OS +.DS_Store +Thumbs.db diff --git a/Modules/Backend/.gitmodules b/Modules/Backend/.gitmodules new file mode 100644 index 0000000..60b8775 --- /dev/null +++ b/Modules/Backend/.gitmodules @@ -0,0 +1,4 @@ +[submodule "Schemas"] + path = Schemas + url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git + branch = develop diff --git a/Modules/Backend/AGENTS.md b/Modules/Backend/AGENTS.md new file mode 100644 index 0000000..3a9c14e --- /dev/null +++ b/Modules/Backend/AGENTS.md @@ -0,0 +1,38 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is a Django REST backend organized by app domains. Core configuration lives in `config/` (`settings.py`, `urls.py`, `celery.py`). Feature apps (for example `account/`, `farm_hub/`, `sensor_catalog/`, `crop_zoning/`, `notifications/`) each contain `models.py`, `serializers.py`, `views.py`, `urls.py`, and app-specific `migrations/`. + +Tests are mostly colocated in each app as `tests.py`. Mock payloads and integration fixtures are stored under `json/mock_data/` and `external_api_adapter/json/`. API collections are under `*/postman/`. + +## Build, Test, and Development Commands +- `python -m venv .venv && source .venv/bin/activate` - create and activate a virtual environment. +- `pip install -r requirements.txt` - install runtime and development dependencies. +- `python manage.py migrate` - apply database migrations. +- `python manage.py runserver` - start the local API server. +- `python manage.py test` - run the full Django test suite. +- `python manage.py test farm_hub` - run tests for a single app. +- `docker compose up --build` - run the backend and dependencies via Docker. + +## Coding Style & Naming Conventions +Follow standard Django/Python conventions: +- 4-space indentation, snake_case for functions/variables, PascalCase for classes. +- Keep serializers in `serializers.py`, business logic in `services.py`, and routing in `urls.py`. +- Name management commands as verbs (for example `seed_admin_user`). +- Prefer small, app-scoped modules over cross-app imports unless shared behavior is intentional. + +## Testing Guidelines +Use Django’s built-in test runner (`unittest` style). Place tests in each app’s `tests.py` (or `tests/` package if expanded). Name tests as `test_` and cover serializers, view responses, and service edge cases. Use `unittest.mock.patch` for external integrations (AI adapters, SMS, or HTTP services). + +## Commit & Pull Request Guidelines +Current history uses generic messages (`UPDATE`), but contributors should use clear, imperative commits such as `add sensor catalog seed command`. + +For PRs, include: +- concise description of scope and affected apps, +- migration notes (`manage.py makemigrations`/`migrate` impact), +- test evidence (`python manage.py test ...` output), +- linked issue/task ID when available, +- request/response examples for API changes (Postman or JSON sample paths). + +## Security & Configuration Tips +Copy `.env.example` to `.env` and never commit secrets. Validate CORS/JWT settings in `config/settings.py` per environment. Keep mock JSON and seed data free of production credentials or personal data. diff --git a/Modules/Backend/AI_INTEGRATION_FLOW_CONTRACT.md b/Modules/Backend/AI_INTEGRATION_FLOW_CONTRACT.md new file mode 100644 index 0000000..da26b65 --- /dev/null +++ b/Modules/Backend/AI_INTEGRATION_FLOW_CONTRACT.md @@ -0,0 +1,26 @@ +# Backend ↔ AI Integration Flow Contract + +## Ownership +- `Backend/plants` owns the canonical plant catalog stored in Backend DB. +- `Ai/farm_data` stores plant catalog snapshots and derived farm read-model data for AI workflows. +- `Backend/farm_alerts` returns persisted tracker snapshots; it does not expose live AI inference on the tracker endpoint. +- `Ai/crop_simulation` owns simulation-derived outputs and live inference tasks. + +## Flow Types +- `direct_proxy`: Backend forwards request/response to AI without changing ownership. +- `backend_owned_data_with_ai_enrichment`: Backend owns the base record and augments it with AI output or AI sync. +- `cached_snapshot`: Response is served from persisted snapshot state. +- `live_ai_inference`: Response or task is generated from live AI execution. +- `ai_owned_derived_output`: AI returns computed or derived outputs from its own services/read-models. + +## Response Metadata +Touched endpoints now expose a top-level `meta` object with: +- `flow_type` +- `source_type` +- `source_service` +- `ownership` +- `live` +- `cached` +- optional `generated_at` +- optional `snapshot_at` +- optional sync fields for Backend plant endpoints diff --git a/Modules/Backend/AI_ROUTE_CONNECTION_AUDIT.md b/Modules/Backend/AI_ROUTE_CONNECTION_AUDIT.md new file mode 100644 index 0000000..3913dec --- /dev/null +++ b/Modules/Backend/AI_ROUTE_CONNECTION_AUDIT.md @@ -0,0 +1,100 @@ +# Backend ↔ AI Route Connection Audit + +Last reconciled against current route registrations and view implementations in: + +- `Backend/config/urls.py` +- `Backend/*/urls.py` +- `Backend/*/views.py` +- `Ai/config/urls.py` +- `Ai/*/urls.py` +- `Backend/external_api_adapter/json/ai/index.json` + +## Status Vocabulary + +- `implemented`: route exists and the corresponding backend ↔ AI integration is implemented now +- `partially_implemented`: route exists, but behavior/readiness is limited or alias-based +- `contract_only`: mock/spec exists, but no real client-facing implementation is registered +- `deprecated`: kept for compatibility or aliasing, but not the preferred canonical route +- `missing`: documented previously, but no route/implementation exists now +- `disabled`: intentionally not exposed for current developer/public use +- `transitional`: works now, but still reflects temporary architecture boundaries or compatibility layers + +## Runtime vs Seed Rule + +- seed/bootstrap data stays allowed for local/dev/test/bootstrap flows +- runtime application code must not silently return mock/sample/demo data +- if real data is missing, the contract must surface an explicit empty state or structured failure + +## Ownership Boundaries + +- Backend owns canonical plant catalog records exposed in `Backend/plants` +- AI `farm_data` owns the derived farm read-model and canonical AI-side farm ↔ plant assignment path +- Backend farm-alert tracker route is cached snapshot delivery, not live AI on request +- AI crop-simulation routes own live or derived simulation outputs + +## Source-Of-Truth Matrix + +| Backend/API contract | Actual route or AI path | Status | Notes | +|---|---|---:|---| +| `POST /api/rag/chat/` | AI only: `Ai/rag/urls.py` | `implemented` | Real AI route; not a backend client route | +| `POST /api/farm-alerts/tracker/` | `Backend/farm_alerts/views.py` → cached snapshot response | `transitional` | Backend route is production-valid, but semantics are `cached_snapshot`, not live AI inference | +| `POST /api/farm-alerts/timeline/` | no backend route | `missing` | Previously documented incorrectly | +| `GET /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `POST /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `GET /api/soil-data/tasks/{task_id}/status/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes | +| `POST /api/soil-data/ndvi-health/` | real backend route is `POST /api/crop-health/ndvi-health/` | `deprecated` | Old path should not be presented as current | +| `POST /api/soile/moisture-heatmap/` | AI route; backend canonical alias is `POST /api/soil/moisture-heatmap/` | `implemented` | `soile/*` is AI-facing, `soil/*` is backend-facing | +| `POST /api/soile/health-summary/` | AI route; backend canonical alias is `POST /api/soil/summary/` | `implemented` | Same as above | +| `POST /api/soile/anomaly-detection/` | AI route; backend canonical alias is `POST /api/soil/anomalies/` | `implemented` | Same as above | +| `POST /api/farm-data/` | AI route exists; backend uses it for sync | `implemented` | Internal AI contract; not a backend public endpoint | +| `GET /api/farm-data/{farm_uuid}/detail/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract | +| `POST /api/farm-data/parameters/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract | +| `POST /api/weather/farm-card/` | backend route exists; AI canonical route also exists | `implemented` | Backend proxies to weather functionality | +| `POST /api/weather/water-need-prediction/` | AI route exists; backend public contract differs | `partially_implemented` | AI path is real; backend public path is different | +| `POST /api/economy/overview/` | backend + AI route exist | `implemented` | End-to-end connected | +| `GET /api/plants/` | AI route exists as `Ai/plant/urls.py` and backend route exists as `GET /api/plants/` | `implemented` | Different services, both real | +| `POST /api/plants/` | AI + backend real | `implemented` | Different services, both real | +| `GET /api/plants/{pk}/` | AI + backend real | `implemented` | Backend is canonical catalog; AI is its own service/snapshot consumer | +| `PUT /api/plants/{pk}/` | AI route real; backend route not exposed with PUT | `partially_implemented` | Real on AI, not mirrored on backend public app | +| `PATCH /api/plants/{pk}/` | AI route real; backend route not exposed with PATCH | `partially_implemented` | Same limitation | +| `DELETE /api/plants/{pk}/` | AI route real; backend route not exposed with DELETE | `partially_implemented` | Same limitation | +| `POST /api/plants/fetch-info/` | AI route real | `implemented` | AI route exists; backend public equivalent is absent | +| `POST /api/pest-disease/detect/` | backend alias + AI route real | `implemented` | Canonical current path | +| `POST /api/pest-disease/risk/` | backend alias + AI route real | `implemented` | Canonical current path | +| `POST /api/pest-disease/risk-summary/` | backend alias route exists | `implemented` | Implemented in backend alias layer | +| `GET /api/irrigation/` | backend + AI real | `implemented` | Canonical list route | +| `POST /api/irrigation/` | AI route real; backend route currently list/create mismatch | `partially_implemented` | Backend public create contract is not yet cleanly reconciled | +| `GET /api/irrigation/{pk}/` | AI route real; backend route missing | `partially_implemented` | Real in AI only | +| `PUT /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Present in mock/spec and AI service, not a backend public route | +| `PATCH /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same | +| `DELETE /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same | +| `POST /api/irrigation/recommend/` | backend + AI real | `implemented` | Canonical route | +| `GET /api/irrigation/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current backend or AI route registration found | +| `POST /api/fertilization/recommend/` | backend + AI real | `implemented` | Canonical route | +| `GET /api/fertilization/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current route registration found | +| `POST /api/crop-simulation/growth/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/` | `deprecated` | Real AI route, but backend public source-of-truth remains under `yield-harvest/*` | +| `GET /api/crop-simulation/growth/{task_id}/status/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/{task_id}/status/` | `deprecated` | Same | +| `POST /api/crop-simulation/current-farm-chart/` | AI route real; backend canonical client route is `/api/yield-harvest/current-farm-chart/` | `deprecated` | Same | +| `POST /api/crop-simulation/harvest-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/harvest-prediction/` | `deprecated` | Same | +| `POST /api/crop-simulation/yield-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/yield-prediction/` | `deprecated` | Same | + +## Response Semantics + +- `farm-alerts/tracker` backend route → `cached snapshot` +- `irrigation/*` backend routes → mostly `proxy` or `backend-owned data with AI enrichment` +- `yield-harvest/*` backend routes → `proxy` to AI plus persisted backend logs for some summaries +- `farm-data/*` AI routes → `AI-owned derived read/write model` + +## Reconciliation Notes + +- `pest-disease/*` is now the real backend alias and AI contract. Older references to `pest-detection/analyze` as the “real” path are stale. +- `farm-alerts/timeline` is not a registered backend route and must not be documented as implemented. +- `soil-data/*`, `farm-data/*`, and several `plants/*` routes are real on the AI service, but not backend public routes; docs must distinguish internal AI contracts from backend client APIs. +- `crop-simulation/*` remains real on AI, while backend public endpoints are exposed under `yield-harvest/*`. +- task status endpoints for fertilization and irrigation recommendation remain mock/spec-only in `Backend/external_api_adapter/json/ai/index.json`. +- schema UI endpoints are intentionally disabled in AI; developers should rely on version-controlled audit docs until schema publishing is intentionally re-enabled. + +## Known Gaps / Follow-up + +- Some backend docs still use historical “AI route” wording where “internal AI contract” would be more precise. +- Some dashboard-era docs still need cleanup where old mock fallback language remains. diff --git a/Modules/Backend/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md b/Modules/Backend/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md new file mode 100644 index 0000000..97c0f97 --- /dev/null +++ b/Modules/Backend/API_USAGE_AUDIT_REQUESTED_ENDPOINTS.md @@ -0,0 +1,68 @@ +# Requested Endpoint Usage Audit + +This file is the backend-facing API status matrix reconciled against current code. + +Status vocabulary: + +- `implemented` +- `partially_implemented` +- `stub/contract-only` +- `deprecated` +- `missing` + +## Endpoint Matrix + +| Endpoint | Backend route | AI route | Status | Notes | +|---|---|---|---:|---| +| `POST /api/weather/farm-card/` | yes | yes | `implemented` | Current backend public weather card route. | +| `POST /api/economy/overview/` | yes | yes | `implemented` | End-to-end route is live. | +| `GET /api/irrigation/` | yes | yes | `implemented` | Method list route is live. | +| `POST /api/irrigation/recommend/` | yes | yes | `implemented` | Recommendation route is live. | +| `POST /api/irrigation/water-stress/` | yes | yes | `implemented` | Backend route proxies to AI-backed water stress flow. | +| `POST /api/fertilization/recommend/` | yes | yes | `implemented` | Live route. | +| `POST /api/pest-disease/detect/` | yes | yes | `implemented` | Canonical current public alias. | +| `POST /api/pest-disease/risk/` | yes | yes | `implemented` | Canonical current public alias. | +| `POST /api/pest-disease/risk-summary/` | yes | no separate AI route | `implemented` | Backend route derives risk summary from the same AI risk integration. | +| `POST /api/farm-alerts/tracker/` | yes | yes | `partially_implemented` | Backend serves snapshot-backed tracker response; not a direct request-time AI invocation. | +| `POST /api/farm-alerts/timeline/` | no | no | `missing` | Was documented, but no route exists. | +| `POST /api/soil/summary/` | yes | n/a | `implemented` | Backend public summary route. | +| `POST /api/soil/anomalies/` | yes | via `POST /api/soile/anomaly-detection/` | `implemented` | Backend canonical route. | +| `POST /api/soil/moisture-heatmap/` | yes | via `POST /api/soile/moisture-heatmap/` | `implemented` | Backend canonical route. | +| `POST /api/crop-health/ndvi-health/` | yes | via `POST /api/soil-data/ndvi-health/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/current-farm-chart/` | yes | via `/api/crop-simulation/current-farm-chart/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/harvest-prediction/` | yes | via `/api/crop-simulation/harvest-prediction/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/yield-prediction/` | yes | via `/api/crop-simulation/yield-prediction/` | `implemented` | Backend canonical route. | +| `POST /api/yield-harvest/growth/` | yes | via `/api/crop-simulation/growth/` | `implemented` | Backend canonical route. | +| `GET /api/yield-harvest/growth/{task_id}/status/` | yes | via `/api/crop-simulation/growth/{task_id}/status/` | `implemented` | Backend canonical route. | +| `GET /api/yield-harvest/summary/` | yes | no | `implemented` | Summary route exists. | +| `GET /api/yield-harvest/yield-harvest-summary/` | yes | via AI summary service | `implemented` | Compatibility alias remains live. | + +## Internal AI Contracts Not To Present As Backend Public APIs + +| Endpoint | Status | Notes | +|---|---:|---| +| `POST /api/rag/chat/` | `implemented` | AI service route only. | +| `GET|POST /api/soil-data/` | `implemented` | AI service route only. | +| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | AI service route only. | +| `POST /api/soile/*` | `implemented` | AI service routes; backend public aliases are under `soil/*`. | +| `POST /api/farm-data/` | `implemented` | AI service route used for integration and sync. | +| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | AI service route. | +| `POST /api/farm-data/parameters/` | `implemented` | AI service route. | +| `POST /api/weather/water-need-prediction/` | `implemented` | AI service route; backend public contract is under `water/*`. | +| `POST /api/crop-simulation/*` | `implemented` | AI service routes; backend public contract is under `yield-harvest/*`. | + +## Contract-Only / Stale Spec Entries + +| Endpoint | Status | Notes | +|---|---:|---| +| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. | +| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. | +| `PUT|PATCH|DELETE /api/irrigation/{pk}/` | `stub/contract-only` | Spec exists, but no backend public route is registered. | + +## Deprecated Path Decisions + +| Old path | Replacement | +|---|---| +| `/api/soil-data/ndvi-health/` | `/api/crop-health/ndvi-health/` | +| `/api/crop-simulation/*` as backend public routes | `/api/yield-harvest/*` | +| `/api/soile/*` as backend public routes | `/api/soil/*` | diff --git a/Modules/Backend/Dockerfile b/Modules/Backend/Dockerfile new file mode 100644 index 0000000..eceae14 --- /dev/null +++ b/Modules/Backend/Dockerfile @@ -0,0 +1,41 @@ +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 . + +# Python 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://mirror2.chabokan.net/pypi/simple && \ + pip config --user set global.trusted-host package-mirror.liara.ir && \ + pip config --user set global.trusted-host mirror2.chabokan.net && \ + pip config --user set global.trusted-host mirror-pypi.runflare.com + +RUN pip install -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"] diff --git a/Modules/Backend/Dockerfile.Dev b/Modules/Backend/Dockerfile.Dev new file mode 100644 index 0000000..debcaf8 --- /dev/null +++ b/Modules/Backend/Dockerfile.Dev @@ -0,0 +1,44 @@ +ARG BASE_IMAGE=mirror-docker.runflare.com/library/python:3.10-slim-bookworm +FROM ${BASE_IMAGE} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +ARG APT_MIRROR=mirror-linux.runflare.com/debian +ARG APT_SECURITY_MIRROR=mirror-linux.runflare.com/debian-security +ARG PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple +ARG PIP_TRUSTED_HOST=mirror-pypi.runflare.com + +WORKDIR /app + +# Route Debian packages through the requested mirror. +RUN printf '%s\n' \ + "deb https://${APT_MIRROR} bookworm main contrib non-free non-free-firmware" \ + "deb https://${APT_MIRROR} bookworm-updates main contrib non-free non-free-firmware" \ + "deb https://${APT_SECURITY_MIRROR} bookworm-security main contrib non-free non-free-firmware" \ + > /etc/apt/sources.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + default-libmysqlclient-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt + +# Route Python packages through the requested mirror. +RUN pip config set global.index-url "${PIP_INDEX_URL}" \ + && pip config set global.trusted-host "${PIP_TRUSTED_HOST}" \ + && pip install -r /app/requirements.txt + +COPY entrypoint.sh /app/entrypoint.sh +COPY . /app + +RUN chmod +x /app/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["sh", "/app/entrypoint.sh"] +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/Modules/Backend/access_control/__init__.py b/Modules/Backend/access_control/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/access_control/apps.py b/Modules/Backend/access_control/apps.py new file mode 100644 index 0000000..19c7abc --- /dev/null +++ b/Modules/Backend/access_control/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccessControlConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "access_control" diff --git a/Modules/Backend/access_control/catalog.py b/Modules/Backend/access_control/catalog.py new file mode 100644 index 0000000..0d23647 --- /dev/null +++ b/Modules/Backend/access_control/catalog.py @@ -0,0 +1 @@ +GOLD_PLAN_CODE = "gold" diff --git a/Modules/Backend/access_control/middleware.py b/Modules/Backend/access_control/middleware.py new file mode 100644 index 0000000..da462d5 --- /dev/null +++ b/Modules/Backend/access_control/middleware.py @@ -0,0 +1,96 @@ +from django.http import JsonResponse +from django.utils.deprecation import MiddlewareMixin +from rest_framework.permissions import AllowAny +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError + +from farm_hub.models import FarmHub + +from .services import ( + AccessControlServiceUnavailable, + authorize_feature, + get_authorization_action, + get_request_data, + get_route_feature_code, +) + + +class RouteFeatureAccessMiddleware(MiddlewareMixin): + def process_view(self, request, view_func, view_args, view_kwargs): + view_class = getattr(view_func, "view_class", None) + if view_class is None: + return None + + if self._allows_anonymous(view_class): + return None + + user = self._get_authenticated_user(request) + if user is None: + return None + + app_label = view_class.__module__.split(".", 1)[0] + feature_code = get_route_feature_code(app_label) + if not feature_code: + return None + + farm_uuid = view_kwargs.get("farm_uuid") or request.GET.get("farm_uuid") or get_request_data(request).get("farm_uuid") + farm = None + if farm_uuid: + try: + farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related( + "products", + "sensors", + "sensors__sensor_catalog", + "sensors__device_catalogs", + ).get(farm_uuid=farm_uuid, owner=user) + except FarmHub.DoesNotExist: + return JsonResponse( + {"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."}, + status=403, + ) + + try: + allowed = authorize_feature( + farm=farm, + user=user, + feature_code=feature_code, + action=get_authorization_action(request.method), + route=request.path, + ) + except AccessControlServiceUnavailable as exc: + return JsonResponse({"code": 503, "msg": str(exc)}, status=503) + + if not allowed: + return JsonResponse( + {"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."}, + status=403, + ) + + request.route_feature_code = feature_code + return None + + @staticmethod + def _allows_anonymous(view_class): + for permission_class in getattr(view_class, "permission_classes", []): + if permission_class is AllowAny: + return True + return False + + @staticmethod + def _get_authenticated_user(request): + if getattr(request, "user", None) is not None and request.user.is_authenticated: + return request.user + + authenticator = JWTAuthentication() + try: + auth_result = authenticator.authenticate(request) + except (InvalidToken, TokenError): + return None + + if auth_result is None: + return None + + user, _token = auth_result + request.user = user + request._cached_user = user + return user diff --git a/Modules/Backend/access_control/migrations/0001_initial.py b/Modules/Backend/access_control/migrations/0001_initial.py new file mode 100644 index 0000000..3fcfd29 --- /dev/null +++ b/Modules/Backend/access_control/migrations/0001_initial.py @@ -0,0 +1,69 @@ +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0006_seed_expanded_product_catalog"), + ("device_hub", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AccessFeature", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("code", models.CharField(db_index=True, max_length=150, unique=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("feature_type", models.CharField(choices=[("page", "Page"), ("widget", "Widget"), ("action", "Action")], default="page", max_length=32)), + ("default_enabled", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "access_features", "ordering": ["name"]}, + ), + migrations.CreateModel( + name="SubscriptionPlan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("code", models.CharField(db_index=True, max_length=100, unique=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "access_subscription_plans", "ordering": ["name"]}, + ), + migrations.CreateModel( + name="AccessRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("code", models.CharField(db_index=True, max_length=150, unique=True)), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("priority", models.PositiveIntegerField(default=100)), + ("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")), + ("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")), + ("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")), + ("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="device_hub.sensorcatalog")), + ("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")), + ], + options={"db_table": "access_rules", "ordering": ["priority", "name"]}, + ), + ] diff --git a/Modules/Backend/access_control/migrations/0002_link_subscription_plan_to_farm.py b/Modules/Backend/access_control/migrations/0002_link_subscription_plan_to_farm.py new file mode 100644 index 0000000..326b4ce --- /dev/null +++ b/Modules/Backend/access_control/migrations/0002_link_subscription_plan_to_farm.py @@ -0,0 +1,25 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0001_initial"), + ("farm_hub", "0006_seed_expanded_product_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FarmAccessProfile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("profile_data", models.JSONField(blank=True, default=dict)), + ("resolved_from_profile", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("farm", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="access_profile", to="farm_hub.farmhub")), + ("subscription_plan", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="farm_access_profiles", to="access_control.subscriptionplan")), + ], + options={"db_table": "farm_access_profiles", "ordering": ["-updated_at"]}, + ), + ] diff --git a/Modules/Backend/access_control/migrations/0003_seed_default_access_rules.py b/Modules/Backend/access_control/migrations/0003_seed_default_access_rules.py new file mode 100644 index 0000000..c2137be --- /dev/null +++ b/Modules/Backend/access_control/migrations/0003_seed_default_access_rules.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0002_link_subscription_plan_to_farm"), + ] + + operations = [] diff --git a/Modules/Backend/access_control/migrations/0004_enable_default_feature_access.py b/Modules/Backend/access_control/migrations/0004_enable_default_feature_access.py new file mode 100644 index 0000000..aaeb6e7 --- /dev/null +++ b/Modules/Backend/access_control/migrations/0004_enable_default_feature_access.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0003_seed_default_access_rules"), + ] + + operations = [] diff --git a/Modules/Backend/access_control/migrations/0005_backfill_farm_subscription_plans.py b/Modules/Backend/access_control/migrations/0005_backfill_farm_subscription_plans.py new file mode 100644 index 0000000..47bd529 --- /dev/null +++ b/Modules/Backend/access_control/migrations/0005_backfill_farm_subscription_plans.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("access_control", "0004_enable_default_feature_access"), + ("farm_hub", "0007_farmhub_subscription_plan"), + ] + + operations = [] diff --git a/Modules/Backend/access_control/migrations/__init__.py b/Modules/Backend/access_control/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/access_control/models.py b/Modules/Backend/access_control/models.py new file mode 100644 index 0000000..9e290da --- /dev/null +++ b/Modules/Backend/access_control/models.py @@ -0,0 +1,104 @@ +import uuid as uuid_lib + +from django.db import models + + +class SubscriptionPlan(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.CharField(max_length=100, unique=True, db_index=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "access_subscription_plans" + ordering = ["name"] + + def __str__(self): + return self.name + + +class AccessFeature(models.Model): + PAGE = "page" + WIDGET = "widget" + ACTION = "action" + FEATURE_TYPES = [ + (PAGE, "Page"), + (WIDGET, "Widget"), + (ACTION, "Action"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.CharField(max_length=150, unique=True, db_index=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE) + default_enabled = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "access_features" + ordering = ["name"] + + def __str__(self): + return self.code + + +class AccessRule(models.Model): + ALLOW = "allow" + DENY = "deny" + EFFECTS = [ + (ALLOW, "Allow"), + (DENY, "Deny"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.CharField(max_length=150, unique=True, db_index=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + priority = models.PositiveIntegerField(default=100) + effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW) + metadata = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + features = models.ManyToManyField("AccessFeature", related_name="rules", blank=True) + subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True) + farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True) + products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True) + sensor_catalogs = models.ManyToManyField("device_hub.DeviceCatalog", related_name="access_rules", blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "access_rules" + ordering = ["priority", "name"] + + def __str__(self): + return self.code + + +class FarmAccessProfile(models.Model): + farm = models.OneToOneField("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="access_profile") + subscription_plan = models.ForeignKey( + "SubscriptionPlan", + on_delete=models.SET_NULL, + related_name="farm_access_profiles", + null=True, + blank=True, + ) + profile_data = models.JSONField(default=dict, blank=True) + resolved_from_profile = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_access_profiles" + ordering = ["-updated_at"] + + def __str__(self): + return f"Access profile for {self.farm_id}" diff --git a/Modules/Backend/access_control/permissions.py b/Modules/Backend/access_control/permissions.py new file mode 100644 index 0000000..8b15b35 --- /dev/null +++ b/Modules/Backend/access_control/permissions.py @@ -0,0 +1,50 @@ +from rest_framework.permissions import BasePermission + +from farm_hub.models import FarmHub + +from .services import AccessControlServiceUnavailable, authorize_feature, get_authorization_action, get_request_data + + +class FeatureAccessPermission(BasePermission): + message = "Access denied." + + def has_permission(self, request, view): + feature_code = getattr(view, "required_feature_code", None) + if not feature_code: + return True + + farm_uuid = ( + view.kwargs.get("farm_uuid") + or request.query_params.get("farm_uuid") + or get_request_data(request).get("farm_uuid") + ) + if not farm_uuid: + self.message = f"Access to feature `{feature_code}` is denied." + return False + + try: + farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related( + "products", + "sensors", + "sensors__sensor_catalog", + "sensors__device_catalogs", + ).get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist: + self.message = f"Access to feature `{feature_code}` is denied." + return False + + try: + allowed = authorize_feature( + farm, + request.user, + feature_code, + get_authorization_action(request.method), + route=request.path, + ) + except AccessControlServiceUnavailable as exc: + self.message = str(exc) + return False + + if not allowed: + self.message = f"Access to feature `{feature_code}` is denied." + return allowed diff --git a/Modules/Backend/access_control/serializers.py b/Modules/Backend/access_control/serializers.py new file mode 100644 index 0000000..df5052f --- /dev/null +++ b/Modules/Backend/access_control/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers + +from .models import SubscriptionPlan + + +class SubscriptionPlanSerializer(serializers.ModelSerializer): + class Meta: + model = SubscriptionPlan + fields = ["uuid", "code", "name"] + + +class FeatureAuthorizationRequestSerializer(serializers.Serializer): + features = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + ) + action = serializers.CharField(required=False, allow_blank=False, default="view") diff --git a/Modules/Backend/access_control/services.py b/Modules/Backend/access_control/services.py new file mode 100644 index 0000000..4e32f4d --- /dev/null +++ b/Modules/Backend/access_control/services.py @@ -0,0 +1,430 @@ +import hashlib +import json +import logging +import time +from functools import lru_cache +from pathlib import Path +from urllib.parse import urljoin + +import requests +from django.conf import settings +from django.core.cache import cache +from django.core.exceptions import ImproperlyConfigured +from django.http import QueryDict +from farm_hub.models import FarmHub +from config.observability import classify_exception, log_event, observe_operation, record_metric + +from .catalog import GOLD_PLAN_CODE +from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan + + +logger = logging.getLogger(__name__) + + +class AccessControlError(Exception): + pass + + +class AccessControlServiceUnavailable(AccessControlError): + pass + + +ACTION_MAP = { + "GET": "view", + "HEAD": "view", + "OPTIONS": "view", + "POST": "create", + "PUT": "edit", + "PATCH": "edit", + "DELETE": "delete", +} + + +def _get_authz_cache_timeout(): + return int(getattr(settings, "ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", 300)) + + +@lru_cache(maxsize=1) +def load_route_feature_map(): + feature_map_path = Path(settings.BASE_DIR) / "config" / "feature.json" + with feature_map_path.open("r", encoding="utf-8") as feature_map_file: + return json.load(feature_map_file) + + +def get_route_feature_code(app_label): + if not app_label: + return None + return load_route_feature_map().get(app_label) + + +def _get_authorization_cache_key(farm, user, features, action, route): + raw_key = json.dumps( + { + "farm_uuid": str(getattr(farm, "farm_uuid", "")), + "user_id": getattr(user, "id", None), + "features": sorted(features), + "action": action, + "route": route or "", + }, + sort_keys=True, + ) + digest = hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + return f"access-control:authz:{digest}" + + +def get_default_subscription_plan(): + return SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first() + + +def get_effective_subscription_plan(farm): + if farm.subscription_plan_id: + return farm.subscription_plan + + default_plan = get_default_subscription_plan() + if default_plan is not None: + return default_plan + + return SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).order_by("name").first() + + +def _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes): + if not rule.is_active: + return False + + if rule.subscription_plans.exists() and (subscription_plan is None or not rule.subscription_plans.filter(pk=subscription_plan.pk).exists()): + return False + + if rule.farm_types.exists() and not rule.farm_types.filter(pk=farm.farm_type_id).exists(): + return False + + if rule.products.exists() and not rule.products.filter(pk__in=product_ids).exists(): + return False + + if rule.sensor_catalogs.exists() and not rule.sensor_catalogs.filter(pk__in=sensor_catalog_ids).exists(): + return False + + metadata_sensor_codes = rule.metadata.get("sensor_catalog_codes", []) + if metadata_sensor_codes and not set(metadata_sensor_codes).intersection(sensor_catalog_codes): + return False + + return True + + +def build_farm_access_profile(farm): + farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related( + "products", + "sensors", + "sensors__sensor_catalog", + "sensors__device_catalogs", + ).get(pk=farm.pk) + + subscription_plan = get_effective_subscription_plan(farm) + product_ids = list(farm.products.values_list("id", flat=True)) + sensor_catalog_ids = set() + sensor_catalog_codes = set() + for sensor in farm.sensors.all(): + for catalog in sensor.get_device_catalogs(): + sensor_catalog_ids.add(catalog.id) + sensor_catalog_codes.add(catalog.code) + + features = { + feature.code: { + "name": feature.name, + "type": feature.feature_type, + "enabled": feature.default_enabled, + "source": "default" if feature.default_enabled else None, + } + for feature in AccessFeature.objects.filter(is_active=True) + } + + matched_rules = [] + rules = AccessRule.objects.filter(is_active=True).prefetch_related( + "features", + "subscription_plans", + "farm_types", + "products", + "sensor_catalogs", + ).order_by("priority", "id") + + for rule in rules: + if not _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes): + continue + + matched_rules.append( + { + "code": rule.code, + "name": rule.name, + "effect": rule.effect, + "priority": rule.priority, + } + ) + for feature in rule.features.all(): + feature_state = features.setdefault( + feature.code, + { + "name": feature.name, + "type": feature.feature_type, + "enabled": feature.default_enabled, + "source": "default" if feature.default_enabled else None, + }, + ) + feature_state["enabled"] = rule.effect == AccessRule.ALLOW + feature_state["source"] = rule.code + + profile = { + "farm_uuid": str(farm.farm_uuid), + "subscription_plan": None, + "features": features, + "matched_rules": matched_rules, + "resolved_from_profile": True, + } + if subscription_plan is not None: + profile["subscription_plan"] = { + "uuid": str(subscription_plan.uuid), + "code": subscription_plan.code, + "name": subscription_plan.name, + } + + FarmAccessProfile.objects.update_or_create( + farm=farm, + defaults={ + "subscription_plan": subscription_plan, + "profile_data": profile, + "resolved_from_profile": True, + }, + ) + return profile + + +def build_opa_resource(farm): + if farm is None: + return { + "farm_id": None, + "subscription_plan_codes": [], + "farm_types": [], + "crop_types": [], + "cultivation_types": [], + "sensor_codes": [], + "power_sensor": [], + "customization": [], + } + + subscription_plan = get_effective_subscription_plan(farm) + sensor_codes = list( + farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True) + ) + power_sensor = [] + for sensor in farm.sensors.all(): + if isinstance(sensor.power_source, dict): + power_type = sensor.power_source.get("type") + if power_type: + power_sensor.append(power_type) + + return { + "farm_id": str(farm.farm_uuid), + "subscription_plan_codes": [subscription_plan.code] if subscription_plan else [], + "farm_types": [farm.farm_type.name] if farm.farm_type_id else [], + "crop_types": list(farm.products.values_list("name", flat=True)), + "cultivation_types": [], + "sensor_codes": sensor_codes, + "power_sensor": power_sensor, + "customization": [], + } + + +def build_opa_user(user): + return { + "id": getattr(user, "id", None), + "username": getattr(user, "username", ""), + "email": getattr(user, "email", ""), + "phone_number": getattr(user, "phone_number", ""), + "is_staff": bool(getattr(user, "is_staff", False)), + "is_superuser": bool(getattr(user, "is_superuser", False)), + "role": "farmer", + } + + +def get_authorization_action(method): + return ACTION_MAP.get(method.upper(), "view") + + +def _opa_url(path): + base_url = getattr(settings, "ACCESS_CONTROL_AUTHZ_BASE_URL", "").strip() + if not base_url: + raise ImproperlyConfigured("ACCESS_CONTROL_AUTHZ_BASE_URL is not configured.") + return urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/")) + + +def build_authorization_input(farm, user, features, action, route=None): + return { + "user": build_opa_user(user), + "resource": build_opa_resource(farm), + "features": list(features), + "action": action, + "route": route, + } + + +def request_opa_batch_authorization(farm, user, features, action, route=None): + if not getattr(settings, "ACCESS_CONTROL_AUTHZ_ENABLED", True): + return {"decisions": {feature: True for feature in features}} + + if not features: + return {"decisions": {}} + + payload = {"input": build_authorization_input(farm, user, features, action, route=route)} + + with observe_operation(source="backend.access_control", provider="opa", operation="batch_authorization"): + started_at = time.monotonic() + try: + response = requests.post( + _opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH), + json=payload, + timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT, + ) + response.raise_for_status() + except requests.RequestException as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="opa batch authorization request failed", + source="backend.access_control", + provider="opa", + operation="batch_authorization", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code=failure.error_code, + route=route, + feature_count=len(features), + ) + record_metric("access_control.opa.failure", error_code=failure.error_code) + raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc + + try: + result = response.json().get("result", {}) + except ValueError as exc: + log_event( + level=logging.ERROR, + message="opa batch authorization returned invalid json", + source="backend.access_control", + provider="opa", + operation="batch_authorization", + result_status="error", + duration_ms=(time.monotonic() - started_at) * 1000, + error_code="parse_error", + route=route, + feature_count=len(features), + status_code=response.status_code, + ) + record_metric("access_control.opa.invalid_json") + raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc + if not result: + record_metric("access_control.opa.empty_result") + logger.warning("OPA returned empty authorization result for route=%s", route) + return result + + +def normalize_opa_batch_result(data, features): + decisions = data.get("decisions") + if isinstance(decisions, dict): + return {feature: bool(decisions.get(feature, False)) for feature in features} + + feature_results = data.get("features") + if isinstance(feature_results, dict): + normalized = {} + for feature in features: + feature_result = feature_results.get(feature, {}) + if isinstance(feature_result, dict): + normalized[feature] = bool(feature_result.get("allow", False)) + else: + normalized[feature] = bool(feature_result) + return normalized + + allowed_features = data.get("allowed_features") + if isinstance(allowed_features, list): + allowed = set(allowed_features) + return {feature: feature in allowed for feature in features} + + if isinstance(data, dict) and all(isinstance(value, bool) for value in data.values()): + return {feature: bool(data.get(feature, False)) for feature in features} + + raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.") + + +def batch_authorize_features(farm, user, features, action, route=None): + if not features: + return {} + + cache_key = _get_authorization_cache_key(farm, user, features, action, route) + + try: + cached_result = cache.get(cache_key) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.WARNING, + message="authorization cache read failed", + source="backend.access_control", + provider="cache", + operation="batch_authorize_features", + result_status="error", + error_code=failure.error_code, + route=route, + ) + cached_result = None + + if isinstance(cached_result, dict): + return {feature: bool(cached_result.get(feature, False)) for feature in features} + + result = request_opa_batch_authorization(farm, user, features, action, route=route) + decisions = normalize_opa_batch_result(result, features) + + try: + cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout()) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.WARNING, + message="authorization cache write failed", + source="backend.access_control", + provider="cache", + operation="batch_authorize_features", + result_status="error", + error_code=failure.error_code, + route=route, + ) + + return decisions + + +def authorize_feature(farm, user, feature_code, action, route=None): + return batch_authorize_features(farm, user, [feature_code], action, route=route).get(feature_code, False) + + +def get_request_data(request): + request_data = getattr(request, "data", None) + if isinstance(request_data, QueryDict): + return request_data + if isinstance(request_data, dict): + return request_data + + cached_body = getattr(request, "_access_control_request_data", None) + if isinstance(cached_body, dict): + return cached_body + + content_type = (getattr(request, "content_type", "") or "").split(";")[0].strip().lower() + body = getattr(request, "body", b"") or b"" + if not body: + return {} + + if content_type == "application/json": + try: + parsed_body = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {} + if isinstance(parsed_body, dict): + request._access_control_request_data = parsed_body + return parsed_body + return {} + + return {} diff --git a/Modules/Backend/access_control/tests.py b/Modules/Backend/access_control/tests.py new file mode 100644 index 0000000..cad76f2 --- /dev/null +++ b/Modules/Backend/access_control/tests.py @@ -0,0 +1,142 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django.test import RequestFactory, SimpleTestCase, override_settings + +from account.views import ProfileView +from config.observability import METRICS + +from .middleware import RouteFeatureAccessMiddleware +from .services import batch_authorize_features, build_authorization_input + + +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "access-control-tests", + } +} + + +@override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300) +class AccessControlServiceTests(SimpleTestCase): + def tearDown(self): + METRICS.clear() + + def test_batch_authorize_features_uses_cache_for_same_route(self): + farm = SimpleNamespace(farm_uuid="farm-uuid") + user = SimpleNamespace(id=7) + + with patch("access_control.services.request_opa_batch_authorization") as mock_request: + mock_request.return_value = {"decisions": {"farm_dashboard": True}} + + first_result = batch_authorize_features( + farm=farm, + user=user, + features=["farm_dashboard"], + action="view", + route="/api/farm-dashboard/", + ) + second_result = batch_authorize_features( + farm=farm, + user=user, + features=["farm_dashboard"], + action="view", + route="/api/farm-dashboard/", + ) + + self.assertEqual(first_result, {"farm_dashboard": True}) + self.assertEqual(second_result, {"farm_dashboard": True}) + self.assertEqual(mock_request.call_count, 1) + + def test_build_authorization_input_includes_route(self): + user = SimpleNamespace( + id=3, + username="tester", + email="tester@example.com", + phone_number="09120000000", + is_staff=False, + is_superuser=False, + ) + + payload = build_authorization_input( + farm=None, + user=user, + features=["account_management"], + action="view", + route="/api/account/profile/", + ) + + self.assertEqual(payload["route"], "/api/account/profile/") + self.assertEqual(payload["resource"]["sensor_codes"], []) + + def test_batch_authorize_features_supports_nested_opa_feature_payload(self): + farm = SimpleNamespace(farm_uuid="farm-uuid") + user = SimpleNamespace(id=9) + + with patch("access_control.services.request_opa_batch_authorization") as mock_request: + mock_request.return_value = { + "features": { + "feature1": {"allow": True, "allow_rules": [], "deny_rules": []}, + "feature2": {"allow": False, "allow_rules": [], "deny_rules": []}, + } + } + + result = batch_authorize_features( + farm=farm, + user=user, + features=["feature1", "feature2", "feature3"], + action="view", + route="/api/farm-dashboard/", + ) + + self.assertEqual( + result, + { + "feature1": True, + "feature2": False, + "feature3": False, + }, + ) + + @patch("access_control.services.requests.post") + @override_settings(ACCESS_CONTROL_AUTHZ_ENABLED=True, ACCESS_CONTROL_AUTHZ_BASE_URL="https://opa.example", ACCESS_CONTROL_AUTHZ_BATCH_PATH="/v1/data/authz", ACCESS_CONTROL_AUTHZ_TIMEOUT=1) + def test_request_opa_batch_authorization_records_invalid_json_metric(self, mock_post): + response = mock_post.return_value + response.raise_for_status.return_value = None + response.json.side_effect = ValueError("bad json") + farm = SimpleNamespace(farm_uuid="farm-uuid") + user = SimpleNamespace(id=7, username="u", email="", phone_number="", is_staff=False, is_superuser=False) + + with self.assertRaises(Exception): + batch_authorize_features( + farm=farm, + user=user, + features=["farm_dashboard"], + action="view", + route="/api/farm-dashboard/", + ) + + self.assertEqual(METRICS["access_control.opa.invalid_json"], 1) + + +class RouteFeatureAccessMiddlewareTests(SimpleTestCase): + def test_middleware_passes_route_feature_and_method_to_service(self): + factory = RequestFactory() + request = factory.patch("/api/account/profile/") + request.user = SimpleNamespace(is_authenticated=True, id=11) + + middleware = RouteFeatureAccessMiddleware(lambda req: None) + view = ProfileView.as_view() + + with patch("access_control.middleware.authorize_feature", return_value=True) as mock_authorize: + response = middleware.process_view(request, view, (), {}) + + self.assertIsNone(response) + mock_authorize.assert_called_once_with( + farm=None, + user=request.user, + feature_code="account_management", + action="edit", + route="/api/account/profile/", + ) diff --git a/Modules/Backend/access_control/urls.py b/Modules/Backend/access_control/urls.py new file mode 100644 index 0000000..60819b2 --- /dev/null +++ b/Modules/Backend/access_control/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import FarmFeatureAuthorizationView + +urlpatterns = [ + path("farms//authorize/", FarmFeatureAuthorizationView.as_view(), name="farm-feature-authorization"), +] diff --git a/Modules/Backend/access_control/views.py b/Modules/Backend/access_control/views.py new file mode 100644 index 0000000..37eb5d0 --- /dev/null +++ b/Modules/Backend/access_control/views.py @@ -0,0 +1,74 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema + +from config.swagger import code_response +from farm_hub.models import FarmHub + +from .serializers import FeatureAuthorizationRequestSerializer +from .services import AccessControlServiceUnavailable, request_opa_batch_authorization + + +class FarmFeatureAuthorizationView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Access Control"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, default="11111111-1111-1111-1111-111111111111"), + ], + request=FeatureAuthorizationRequestSerializer, + responses={200: code_response("FarmFeatureAuthorizationResponse")}, + ) + def post(self, request, farm_uuid): + serializer = FeatureAuthorizationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + farm = FarmHub.objects.select_related("subscription_plan", "farm_type").prefetch_related( + "products", + "sensors", + "sensors__sensor_catalog", + "sensors__device_catalogs", + ).get( + farm_uuid=farm_uuid, + owner=request.user, + ) + except FarmHub.DoesNotExist: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + try: + opa_result = request_opa_batch_authorization( + farm=farm, + user=request.user, + features=serializer.validated_data["features"], + action=serializer.validated_data["action"], + route=request.path, + ) + except AccessControlServiceUnavailable as exc: + return Response( + {"code": 503, "msg": str(exc)}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response( + { + "code": 200, + "msg": "success", + "data": { + "farm_uuid": str(farm.farm_uuid), + "user": { + "id": request.user.id, + "username": request.user.username, + "email": request.user.email, + "phone_number": getattr(request.user, "phone_number", ""), + }, + "features": serializer.validated_data["features"], + "action": serializer.validated_data["action"], + "decision": opa_result, + }, + }, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/account/__init__.py b/Modules/Backend/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/account/apps.py b/Modules/Backend/account/apps.py new file mode 100644 index 0000000..2c684a9 --- /dev/null +++ b/Modules/Backend/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "account" diff --git a/Modules/Backend/account/backends.py b/Modules/Backend/account/backends.py new file mode 100644 index 0000000..c9fca01 --- /dev/null +++ b/Modules/Backend/account/backends.py @@ -0,0 +1,25 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + +User = get_user_model() + + +class MultiFieldBackend(ModelBackend): + """Authenticate with username, email, or phone_number.""" + + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None or password is None: + return None + + try: + user = User.objects.get( + Q(username=username) | Q(email=username) | Q(phone_number=username) + ) + except (User.DoesNotExist, User.MultipleObjectsReturned): + User().set_password(password) + return None + + if user.check_password(password) and self.user_can_authenticate(user): + return user + return None diff --git a/Modules/Backend/account/management/__init__.py b/Modules/Backend/account/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/account/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/account/management/commands/__init__.py b/Modules/Backend/account/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/account/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/account/management/commands/seed_admin_user.py b/Modules/Backend/account/management/commands/seed_admin_user.py new file mode 100644 index 0000000..1be19ca --- /dev/null +++ b/Modules/Backend/account/management/commands/seed_admin_user.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand, CommandError + +from account.seeds import ADMIN_USER_DATA +from farm_hub.seeds import seed_admin_farm + + +class Command(BaseCommand): + help = "Create or update the default admin user through the admin farm seeder." + + def handle(self, *args, **options): + try: + farm, created = seed_admin_farm() + except ValueError as exc: + raise CommandError(str(exc)) from exc + + action = "created" if created else "updated" + user = farm.owner + self.stdout.write( + self.style.SUCCESS( + f"Admin user {action}: username={user.username}, email={user.email}, " + f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}, " + f"farm_uuid={farm.farm_uuid}" + ) + ) diff --git a/Modules/Backend/account/migrations/0001_initial.py b/Modules/Backend/account/migrations/0001_initial.py new file mode 100644 index 0000000..d5210d9 --- /dev/null +++ b/Modules/Backend/account/migrations/0001_initial.py @@ -0,0 +1,134 @@ +# Generated by Django 5.2.11 on 2026-03-18 14:09 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "phone_number", + models.CharField(db_index=True, max_length=32, unique=True), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "db_table": "users", + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/Modules/Backend/account/migrations/0002_alter_user_managers_alter_user_email.py b/Modules/Backend/account/migrations/0002_alter_user_managers_alter_user_email.py new file mode 100644 index 0000000..91f4ca5 --- /dev/null +++ b/Modules/Backend/account/migrations/0002_alter_user_managers_alter_user_email.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.15 on 2026-03-23 18:48 + +import account.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', account.models.CustomUserManager()), + ], + ), + migrations.AlterField( + model_name='user', + name='email', + field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email address'), + ), + ] diff --git a/Modules/Backend/account/migrations/__init__.py b/Modules/Backend/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/account/models.py b/Modules/Backend/account/models.py new file mode 100644 index 0000000..bb04f7b --- /dev/null +++ b/Modules/Backend/account/models.py @@ -0,0 +1,37 @@ +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import UserManager as BaseUserManager +from django.db.models import Q +from django.db import models + + +class CustomUserManager(BaseUserManager): + """Manager that allows lookup by username, email, or phone_number.""" + + def get_by_natural_key(self, username): + return self.get( + Q(username=username) | Q(email=username) | Q(phone_number=username) + ) + + +class User(AbstractUser): + phone_number = models.CharField( + max_length=32, + unique=True, + db_index=True, + ) + email = models.EmailField( + "email address", + unique=True, + db_index=True, + ) + + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email", "phone_number"] + + objects = CustomUserManager() + + class Meta: + db_table = "users" + + def __str__(self): + return self.username diff --git a/Modules/Backend/account/postman/account.json b/Modules/Backend/account/postman/account.json new file mode 100644 index 0000000..efc03e9 --- /dev/null +++ b/Modules/Backend/account/postman/account.json @@ -0,0 +1,146 @@ +{ + "info": { + "name": "Account", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Account API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, PATCH profile. Authenticated user required." + }, + + "item": [ + { + "name": "Update profile", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"email\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/account/profile/", + "description": "Update current user profile (first_name, last_name, email). Returns UpdateProfileResponse." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n }\n}" + } + ] + }, + { + "name": "List accounts", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/", + "description": "Get list of accounts. GET on base route." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}" + } + ] + }, + { + "name": "Get account detail (by uuid)", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Get one account by uuid in path." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}" + } + ] + }, + { + "name": "Add account", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}" + }, + "url": "{{baseUrl}}/api/account/", + "description": "Add a new account. POST on base route." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + }, + { + "name": "Update account", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}" + }, + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Update account by uuid in path. PATCH." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + }, + { + "name": "Delete account", + "request": { + "method": "DELETE", + "header": [ + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/account/{{uuid}}/", + "description": "Delete account by uuid in path." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}" + } + ] + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""}, + {"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"} + ] +} diff --git a/Modules/Backend/account/seeds.py b/Modules/Backend/account/seeds.py new file mode 100644 index 0000000..2449ed4 --- /dev/null +++ b/Modules/Backend/account/seeds.py @@ -0,0 +1,44 @@ +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Q + + +ADMIN_USER_DATA = { + "username": "admin", + "email": "admin@example.com", + "phone_number": "0912345678", + "first_name": "admin", + "last_name": "admin", + "password": "admin123456", +} + + +@transaction.atomic +def seed_admin_user(): + user_model = get_user_model() + lookup = ( + Q(username=ADMIN_USER_DATA["username"]) + | Q(email=ADMIN_USER_DATA["email"]) + | Q(phone_number=ADMIN_USER_DATA["phone_number"]) + ) + matched_users = list(user_model.objects.filter(lookup).order_by("id")) + + if len(matched_users) > 1: + raise ValueError( + "Multiple users matched the admin seeder lookup. Resolve duplicates before seeding." + ) + + created = not matched_users + user = matched_users[0] if matched_users else user_model() + user.username = ADMIN_USER_DATA["username"] + user.email = ADMIN_USER_DATA["email"] + user.phone_number = ADMIN_USER_DATA["phone_number"] + user.first_name = ADMIN_USER_DATA["first_name"] + user.last_name = ADMIN_USER_DATA["last_name"] + user.is_staff = True + user.is_superuser = True + user.is_active = True + user.set_password(ADMIN_USER_DATA["password"]) + user.save() + + return user, created diff --git a/Modules/Backend/account/serializers.py b/Modules/Backend/account/serializers.py new file mode 100644 index 0000000..65f1c53 --- /dev/null +++ b/Modules/Backend/account/serializers.py @@ -0,0 +1,16 @@ +""" +Account API serializers. +UpdateProfile request/response shapes aligned with frontend types. +""" + +from rest_framework import serializers + + +class UpdateProfileSerializer(serializers.Serializer): + """ + Request body for PATCH /api/account/profile/ (UpdateProfilePayload). + """ + + first_name = serializers.CharField(max_length=150, required=False, allow_blank=True) + last_name = serializers.CharField(max_length=150, required=False, allow_blank=True) + email = serializers.EmailField(required=False, allow_blank=True) diff --git a/Modules/Backend/account/urls.py b/Modules/Backend/account/urls.py new file mode 100644 index 0000000..e8b667e --- /dev/null +++ b/Modules/Backend/account/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AccountView, ProfileView + +urlpatterns = [ + path("profile/", ProfileView.as_view(), name="profile-update"), + # path("/", AccountView.as_view(), name="account-detail"), + # path("", AccountView.as_view(), name="account-list"), +] diff --git a/Modules/Backend/account/views.py b/Modules/Backend/account/views.py new file mode 100644 index 0000000..e8aa686 --- /dev/null +++ b/Modules/Backend/account/views.py @@ -0,0 +1,160 @@ +""" +Account API module. +CRUD endpoints for user account profile. +""" + +from rest_framework import serializers +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view + +from auth.serializers import AuthUserSerializer +from config.swagger import code_response +from .serializers import UpdateProfileSerializer + + +def _auth_user_to_data(user): + """Build AuthUser-shaped dict from Django User.""" + if user is None or not getattr(user, "pk", None): + return None + return { + "id": user.id, + "username": getattr(user, "username", "") or "", + "email": getattr(user, "email", "") or "", + "first_name": getattr(user, "first_name", "") or "", + "last_name": getattr(user, "last_name", "") or "", + "phone_number": getattr(user, "phone_number", "") or "", + } + + +@extend_schema_view( + patch=extend_schema( + tags=["Account"], + request=UpdateProfileSerializer, + responses={200: code_response("ProfileUpdateResponse", data=AuthUserSerializer())}, + ), +) +class ProfileView(APIView): + """ + PATCH /api/account/profile/ + UpdateProfilePayload: first_name, last_name, email. + UpdateProfileResponse: code, msg, data (AuthUser). + """ + + permission_classes = [IsAuthenticated] + + def patch(self, request): + serializer = UpdateProfileSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + user = request.user + for field in ("first_name", "last_name", "email"): + if field in serializer.validated_data: + setattr(user, field, serializer.validated_data[field]) + user.save(update_fields=[ + f for f in ("first_name", "last_name", "email") + if f in serializer.validated_data + ]) + + data = _auth_user_to_data(user) + if data is None: + data = { + "id": 0, + "username": "", + "email": "", + "first_name": "", + "last_name": "", + "phone_number": "", + } + return Response( + {"code": 200, "msg": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + get=extend_schema( + tags=["Account"], + responses={200: code_response("AccountGetResponse", data=serializers.JSONField())}, + ), + post=extend_schema( + tags=["Account"], + request=OpenApiTypes.OBJECT, + responses={200: code_response("AccountCreateResponse")}, + ), + patch=extend_schema( + tags=["Account"], + request=OpenApiTypes.OBJECT, + responses={200: code_response("AccountUpdateResponse")}, + ), + delete=extend_schema( + tags=["Account"], + responses={200: code_response("AccountDeleteResponse")}, + ), +) +class AccountView(APIView): + """ + Account CRUD endpoints. Dispatch by HTTP method and path (uuid for detail/update/delete). + No processing, validation, or transformation is applied to any input. + All endpoints return HTTP 200 only. Response format: {"code": 200, "msg": "success"} or {"code": 200, "msg": "success", "data": {}}. + + Routes: + - GET "" → List: returns status "success", data {}. + - GET "/" → Detail: uuid (path). Returns status "success", data {}. + - POST "" → Create: body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field. + - PATCH "/" → Update: uuid (path), body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field. + - DELETE "/" → Delete: uuid (path). Returns status "success". No data field. + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + """ + List or detail account. + + List (GET on base URL): + - Input parameters: none required. Query params if sent are not processed. + - Response: {"code": 200, "msg": "success", "data": {}}. + - No processing or validation is performed on inputs. + + Detail (GET on /): + - Input parameters: uuid (path, UUID). Description: identifier for the account resource. + - Response: {"code": 200, "msg": "success", "data": {}}. + - No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success", "data": {}}, status=status.HTTP_200_OK) + + def post(self, request, *args, **kwargs): + """ + Create account. + + Input parameters (body, JSON): first_name (string), last_name (string), phones (array of strings). + Description: intended for user first name, last name, and phone numbers. Not processed or validated. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def patch(self, request, *args, **kwargs): + """ + Update account. + + Input parameters: uuid (path, UUID), body (JSON) may contain first_name, last_name, phones. + Description: identifier in path; body fields intended for updated profile. Not processed or validated. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + def delete(self, request, *args, **kwargs): + """ + Delete account. + + Input parameters: uuid (path, UUID). Description: identifier for the account resource to delete. + Response: {"code": 200, "msg": "success"}. No data field. + No processing or validation is performed on inputs. + """ + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/api_changes_last_6_commits_combined.md b/Modules/Backend/api_changes_last_6_commits_combined.md new file mode 100644 index 0000000..bf03911 --- /dev/null +++ b/Modules/Backend/api_changes_last_6_commits_combined.md @@ -0,0 +1,232 @@ +# گزارش تغییرات API در ۶ کامیت اخیر + +این فایل تغییرات مربوط به سه فایل زیر را نسبت به **۶ کامیت قبل** (`HEAD~6`) مستند می‌کند: + +- `irrigation/urls.py` +- `fertilization/apps.py` +- `farm_ai_assistant/views.py` + +## بازه مقایسه +- مبدا: `HEAD~6` +- مقصد: `HEAD` + +## نتیجه سریع +- در `irrigation/urls.py`، API آبیاری از مدل دارای endpoint بررسی وضعیت task فاصله گرفته و دو endpoint جدید برای لیست روش‌های آبیاری و water stress اضافه شده است. +- در `fertilization/apps.py`، در این بازه **هیچ تغییری** ثبت نشده است. +- در `farm_ai_assistant/views.py`، API چت از flow مبتنی بر task/polling به flow مستقیم request/response تغییر کرده و پشتیبانی از `history`، `image_urls` و ورودی‌های multipart/JSON بهتر شده است. + +## 1) تغییرات `irrigation/urls.py` + +### وضعیت قبلی +مسیرهای زیر وجود داشتند: +- `config/` -> `ConfigView` +- `recommend/` -> `RecommendView` +- `recommend/status//` -> `RecommendTaskStatusView` + +### وضعیت فعلی +مسیرهای فعلی: +- `` -> `IrrigationMethodListView` +- `config/` -> `ConfigView` +- `recommend/` -> `RecommendView` +- `water-stress/` -> `WaterStressView` + +### تغییرات دقیق +#### الف) اضافه شدن endpoint ریشه برای لیست روش‌های آبیاری +مسیر جدید: +- `GET irrigation/` +- view: `IrrigationMethodListView` +- name: `irrigation-method-list` + +اثر: +- حالا این app علاوه بر recommendation، یک endpoint مستقل برای دریافت لیست روش‌های آبیاری هم دارد. + +#### ب) حذف endpoint بررسی وضعیت task +مسیر حذف‌شده: +- `recommend/status//` +- view: `RecommendTaskStatusView` +- name: `irrigation-recommendation-task-status` + +اثر: +- دیگر API رسمی‌ای در `urls.py` برای polling وضعیت task recommendation تعریف نشده است. +- این تغییر از نظر معماری شبیه حذف flow تسک‌محور در بخش AI assistant است. + +#### ج) اضافه شدن endpoint جدید water stress +مسیر جدید: +- `POST irrigation/water-stress/` +- view: `WaterStressView` +- name: `irrigation-water-stress` + +اثر: +- یک قابلیت جدید در API آبیاری اضافه شده که به‌صورت جداگانه water stress را محاسبه/برمی‌گرداند. + +### چیزهایی که تغییر نکرده‌اند +- `config/` +- `recommend/` + +## 2) تغییرات `fertilization/apps.py` + +در بازه `HEAD~6..HEAD` برای این فایل **هیچ diffای وجود ندارد**. + +### وضعیت فعلی و قبلی یکسان است +مقدارهای مهم بدون تغییر مانده‌اند: +- `default_auto_field = "django.db.models.BigAutoField"` +- `name = "fertilization"` +- `verbose_name = "Fertilization Recommendation"` + +### نتیجه +- از نظر ثبت app در Django، در این ۶ کامیت اخیر تغییری در `fertilization/apps.py` اعمال نشده است. +- اگر منظورت بررسی APIهای recommendation بوده، این فایل خودش route یا view API ندارد و فقط تنظیمات app را نگه می‌دارد. + +## 3) تغییرات `farm_ai_assistant/views.py` + +بزرگ‌ترین تغییرات این بازه در این فایل اتفاق افتاده است. + +### خلاصه معماری +API چت از این مدل: +1. ثبت درخواست چت +2. ساخت task در سرویس AI +3. دریافت `task_id` +4. polling برای status + +به این مدل تغییر کرده: +1. ارسال مستقیم درخواست چت +2. دریافت مستقیم پاسخ assistant +3. ذخیره هم‌زمان پیام کاربر و پیام assistant + +### تغییرات مهم +#### الف) حذف flow مبتنی بر task +کلاس‌های زیر حذف شده‌اند: +- `ChatTaskCreateView` +- `ChatTaskStatusView` + +رفتار قبلی: +- `ChatTaskCreateView` درخواست را به endpoint زیر در سرویس بیرونی می‌فرستاد: + - `/rag/chat/generate` +- سپس `ChatTaskStatusView` وضعیت task را از endpoint زیر می‌گرفت: + - `/tasks/{task_id}/status` + +رفتار جدید: +- این دو کلاس حذف شده‌اند و flow task-based از این فایل کنار رفته است. + +اثر: +- دیگر پاسخ چت در دو مرحله create/status مدیریت نمی‌شود. +- polling مبتنی بر `task_id` از منطق این viewها حذف شده است. + +#### ب) مستقیم شدن درخواست چت در `ChatView` +در `ChatView.post`، اکنون درخواست مستقیم به سرویس AI ارسال می‌شود: +- endpoint جدید آداپتر: `/api/rag/chat/` + +این یعنی: +- به‌جای submit task و پیگیری وضعیت آن، پاسخ assistant در همان request برگردانده می‌شود. + +#### ج) تغییر مدل ورودی از `content` به `query` +در payload ارسالی به adapter، حالا فیلد اصلی متن کاربر این است: +- `query` + +در نسخه قبلی، از `content` برای ساخت payload استفاده می‌شد. +الان: +- `content` در منطق اصلی جای خود را به `query` داده است. + +اثر: +- کلاینتی که هنوز payload قدیمی مبتنی بر `content` می‌فرستد، باید با قرارداد جدید view/serializer هماهنگ شود. + +#### د) پشتیبانی از `history` +قابلیت جدید: +- history پیام‌ها به‌صورت ساختاریافته دریافت، نرمال‌سازی و به adapter ارسال می‌شود. + +تغییرات مرتبط: +- اضافه شدن `_serialize_history_messages` +- اضافه شدن `_merge_history` +- اضافه شدن `history` به payload ارسالی به سرویس بیرونی + +اثر: +- API حالا می‌تواند context مکالمه را شفاف‌تر و مستقل از صرفاً conversation id به سرویس AI بفرستد. +- اگر history در ورودی باشد، استفاده می‌شود؛ در غیر این صورت از پیام‌های conversation برای ساخت history استفاده می‌شود. + +#### ه) پشتیبانی از `image_urls` و فایل آپلودی +قابلیت‌های جدید: +- `image_urls` به payload اضافه شده است. +- فایل‌های آپلودشده نیز جمع‌آوری و به payload الصاق می‌شوند. + +تغییرات مرتبط: +- اضافه شدن `_attach_uploaded_files` +- تبدیل `history` و `image_urls` به JSON string هنگام multipart submission +- ادغام `image_urls` و `images` در ذخیره پیام کاربر + +اثر: +- API چت حالا هم لینک تصویر و هم فایل تصویر آپلودی را پشتیبانی می‌کند. +- درخواست‌های multipart برای سناریوهای image-based chat بهتر پشتیبانی می‌شوند. + +#### و) مدیریت بهتر JSON و خطای Parse +قابلیت‌های جدید: +- import شدن `ParseError` +- اضافه شدن `_parse_json_array` +- اضافه شدن `_prepare_chat_input` +- پاسخ ۴۰۰ اختصاصی برای JSON نامعتبر + +رفتار جدید: +- اگر body نامعتبر باشد، API این پیام را برمی‌گرداند: + - invalid JSON / extra trailing characters +- فیلدهایی مثل `history` و `image_urls` اگر به شکل string JSON بیایند، parse می‌شوند. + +اثر: +- endpoint چت در برابر فرمت‌های مختلف درخواست مقاوم‌تر شده است. +- احتمال خطا برای کلاینت‌هایی که multipart یا JSON string می‌فرستند کمتر شده است. + +#### ز) تغییر در ساخت conversation جدید +رفتار جدید: +- عنوان conversation با `_generate_conversation_title(query)` ساخته می‌شود. +- اگر query خالی باشد، عنوان پیش‌فرض `Image` می‌شود. +- برای conversation جدید، `farm_context` به‌صورت خالی `{}` ست می‌شود. + +رفتار حذف‌شده: +- دیگر `farm_context` و `title` مستقیماً از payload برای update/create conversation استفاده نمی‌شوند. +- منطق قبلی که conversation موجود را با `farm_context` یا `title` آپدیت می‌کرد حذف شده است. + +اثر: +- کنترل عنوان مکالمه بیشتر به منطق داخلی view منتقل شده است. +- payload کلاینت اختیار کمتری روی title/farm_context conversation دارد. + +#### ح) بهبود نرمال‌سازی پاسخ assistant +در نرمال‌سازی sectionها، این کلیدها هم پشتیبانی می‌شوند: +- `primaryAction` +- `validityPeriod` + +اثر: +- ساختار response assistant برای UIهای غنی‌تر آماده‌تر شده است. + +#### ط) حذف وابستگی به mock chat response +حذف شده: +- `CHAT_RESPONSE_DATA` +- متد `_build_mock_assistant_payload` + +اثر: +- منطق چت بیشتر به پاسخ واقعی adapter متکی شده و از mock response داخلی فاصله گرفته است. + +#### ی) logging بیشتر برای دیباگ integration +موارد جدید: +- import شدن `logging` +- تعریف `logger` +- ثبت log برای response adapter و برخی وضعیت‌های payload parsing/extraction + +اثر: +- عیب‌یابی integration با سرویس AI ساده‌تر شده است. + +## جمع‌بندی نهایی +در این ۶ کامیت اخیر: + +- `irrigation/urls.py` + - endpoint بررسی وضعیت task حذف شده + - endpoint ریشه برای لیست روش‌های آبیاری اضافه شده + - endpoint جدید `water-stress/` اضافه شده + +- `fertilization/apps.py` + - هیچ تغییری نداشته است + +- `farm_ai_assistant/views.py` + - flow چت task-based حذف شده + - درخواست مستقیم به `/api/rag/chat/` جایگزین شده + - پشتیبانی از `history`، `image_urls` و فایل آپلودی اضافه شده + - مدیریت JSON/multipart بهتر شده + - title conversation از `query` ساخته می‌شود + - نرمال‌سازی response assistant گسترش یافته است diff --git a/Modules/Backend/auth/__init__.py b/Modules/Backend/auth/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/auth/apps.py b/Modules/Backend/auth/apps.py new file mode 100644 index 0000000..4861118 --- /dev/null +++ b/Modules/Backend/auth/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AuthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auth" + label = "auth_api" # Avoid clash with django.contrib.auth (label "auth") diff --git a/Modules/Backend/auth/postman/postman.json b/Modules/Backend/auth/postman/postman.json new file mode 100644 index 0000000..5c9b690 --- /dev/null +++ b/Modules/Backend/auth/postman/postman.json @@ -0,0 +1,59 @@ +{ + "info": { + "name": "Auth", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Auth API. Request OTP, Verify OTP." + }, + "item": [ + { + "name": "Request OTP", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"phone_number\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/request-otp/", + "description": "Request OTP for the given phone number. In DEBUG mode, response includes debug_note." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"token\": \"\"\n}" + } + ] + }, + { + "name": "Verify OTP", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"} + ], + "body": { + "mode": "raw", + "raw": "{\n \"token\": \"\",\n \"otp_code\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/verify-otp/", + "description": "Verify OTP with token from request-otp and otp_code sent to user." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n },\n \"token\": \"\"\n}" + } + ] + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""} + ] +} diff --git a/Modules/Backend/auth/serializers.py b/Modules/Backend/auth/serializers.py new file mode 100644 index 0000000..65b95dd --- /dev/null +++ b/Modules/Backend/auth/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + + +# --- Register --- +class RegisterSerializer(serializers.Serializer): + """Request body for POST /api/auth/register/.""" + + username = serializers.CharField(max_length=150) + email = serializers.EmailField() + phone_number = serializers.CharField(max_length=32) + password = serializers.CharField(min_length=8, write_only=True) + first_name = serializers.CharField(max_length=150, required=False, default="") + last_name = serializers.CharField(max_length=150, required=False, default="") + + +# --- Login --- +class LoginSerializer(serializers.Serializer): + """Request body for POST /api/auth/login/. + identifier can be username, email, or phone_number.""" + + identifier = serializers.CharField() + password = serializers.CharField(min_length=8, write_only=True) + + +# --- RequestOTP (request-otp/) --- +class RequestOTPSerializer(serializers.Serializer): + """Request body for POST /api/auth/request-otp/.""" + + phone_number = serializers.CharField(max_length=32) + + +# --- VerifyOTP (verify-otp/) --- +class VerifyOTPSerializer(serializers.Serializer): + """Request body for POST /api/auth/verify-otp/.""" + + token = serializers.CharField() + otp_code = serializers.CharField(max_length=10) + + +# --- AuthUser (used in VerifyOTPResponse and UpdateProfileResponse) --- +class AuthUserSerializer(serializers.Serializer): + """User data returned in auth/account responses.""" + + id = serializers.IntegerField() + username = serializers.CharField() + email = serializers.EmailField(allow_blank=True) + first_name = serializers.CharField() + last_name = serializers.CharField() + phone_number = serializers.CharField() diff --git a/Modules/Backend/auth/sms_service.py b/Modules/Backend/auth/sms_service.py new file mode 100644 index 0000000..3c693cf --- /dev/null +++ b/Modules/Backend/auth/sms_service.py @@ -0,0 +1,59 @@ +import http.client +import json +import logging + +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def send_otp_sms(phone_number: str, otp_code: str) -> bool: + """Send OTP code via SMS.ir bulk API. + + Returns True on success, False on failure. + """ + api_key = getattr(settings, "SMS_IR_API_KEY", "") + line_number = getattr(settings, "SMS_IR_LINE_NUMBER", 300000000000) + + if not api_key: + logger.error("SMS_IR_API_KEY is not configured.") + return False + + message_text = f"کد تایید شما: {otp_code}" + + payload = json.dumps({ + "lineNumber": line_number, + "messageText": message_text, + "mobiles": [phone_number], + "sendDateTime": None, + }) + + headers = { + "X-API-KEY": api_key, + "Content-Type": "application/json", + } + + try: + conn = http.client.HTTPSConnection("api.sms.ir") + conn.request("POST", "/v1/send/bulk", payload, headers) + res = conn.getresponse() + data = res.read().decode("utf-8") + conn.close() + + response = json.loads(data) + status_code = response.get("status") + + if res.status == 200 and status_code == 1: + logger.info("SMS sent successfully to %s", phone_number) + return True + + logger.warning( + "SMS.ir returned unexpected response: HTTP %s, body: %s", + res.status, + data, + ) + return False + + except Exception: + logger.exception("Failed to send SMS to %s", phone_number) + return False diff --git a/Modules/Backend/auth/urls.py b/Modules/Backend/auth/urls.py new file mode 100644 index 0000000..08bb538 --- /dev/null +++ b/Modules/Backend/auth/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import AuthenticationView, LoginView, RegisterView + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + path("login/", LoginView.as_view(), name="login"), + # path("request-otp/", AuthenticationView.as_view(), name="request-otp"), + # path("verify-otp/", AuthenticationView.as_view(), name="verify-otp"), +] diff --git a/Modules/Backend/auth/views.py b/Modules/Backend/auth/views.py new file mode 100644 index 0000000..fee89b2 --- /dev/null +++ b/Modules/Backend/auth/views.py @@ -0,0 +1,208 @@ +import secrets + +from django.conf import settings +from django.contrib.auth import authenticate +from django.core.cache import cache +from django.core.signing import BadSignature, SignatureExpired, TimestampSigner +from django.db import IntegrityError +from rest_framework import serializers, status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework_simplejwt.tokens import AccessToken + +from account.models import User +from config.swagger import code_response +from .serializers import ( + AuthUserSerializer, + LoginSerializer, + RegisterSerializer, + RequestOTPSerializer, + VerifyOTPSerializer, +) +from .sms_service import send_otp_sms + + +OTP_TTL_SECONDS = 300 +OTP_SIGNER = TimestampSigner(salt="auth.otp") + + +def _auth_user_to_data(user): + if user is None or not getattr(user, "pk", None): + return None + return { + "id": user.id, + "username": getattr(user, "username", "") or "", + "email": getattr(user, "email", "") or "", + "first_name": getattr(user, "first_name", "") or "", + "last_name": getattr(user, "last_name", "") or "", + "phone_number": getattr(user, "phone_number", "") or "", + } + + +def _issue_token(user): + return str(AccessToken.for_user(user)) + + +@extend_schema_view( + post=extend_schema( + tags=["Authentication"], + request=RegisterSerializer, + responses={ + 201: code_response("RegisterResponse", data=AuthUserSerializer(), token=True), + 400: code_response("RegisterErrorResponse"), + }, + ), +) +class RegisterView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + try: + user = User.objects.create_user( + username=data["username"], + email=data["email"], + phone_number=data["phone_number"], + password=data["password"], + first_name=data.get("first_name", ""), + last_name=data.get("last_name", ""), + ) + except IntegrityError as exc: + msg = str(exc).lower() + if "username" in msg: + detail = "A user with this username already exists." + elif "email" in msg: + detail = "A user with this email already exists." + elif "phone_number" in msg: + detail = "A user with this phone number already exists." + else: + detail = "A user with these credentials already exists." + return Response({"code": 400, "msg": detail}, status=status.HTTP_400_BAD_REQUEST) + + return Response( + { + "code": 201, + "msg": "success", + "data": _auth_user_to_data(user), + "token": _issue_token(user), + }, + status=status.HTTP_201_CREATED, + ) + + +@extend_schema_view( + post=extend_schema( + tags=["Authentication"], + request=LoginSerializer, + responses={ + 200: code_response("LoginResponse", data=AuthUserSerializer(), token=True), + 401: code_response("LoginErrorResponse"), + }, + ), +) +class LoginView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + identifier = serializer.validated_data["identifier"] + password = serializer.validated_data["password"] + user = authenticate(request, username=identifier, password=password) + + if user is None: + return Response({"code": 401, "msg": "Invalid credentials."}, status=status.HTTP_401_UNAUTHORIZED) + + return Response( + { + "code": 200, + "msg": "success", + "data": _auth_user_to_data(user), + "token": _issue_token(user), + }, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + post=extend_schema( + tags=["Authentication"], + request=RequestOTPSerializer, + responses={ + 200: code_response( + "RequestOtpResponse", + extra_fields={ + "token": serializers.CharField(), + "sms_warning": serializers.CharField(required=False), + "debug_otp": serializers.CharField(required=False), + }, + ), + 400: code_response("RequestOtpErrorResponse"), + }, + ), +) +class AuthenticationView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + if "verify-otp" in request.path: + return self._verify_otp(request) + return self._request_otp(request) + + def _request_otp(self, request): + serializer = RequestOTPSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + phone_number = serializer.validated_data["phone_number"].strip() + otp_code = f"{secrets.randbelow(1_000_000):06d}" + cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS) + otp_token = OTP_SIGNER.sign(phone_number) + sms_sent = send_otp_sms(phone_number, otp_code) + + payload = {"code": 200, "msg": "success", "token": otp_token} + if not sms_sent: + payload["sms_warning"] = "SMS delivery failed; OTP stored server-side." + if settings.DEBUG: + payload["debug_otp"] = otp_code + return Response(payload, status=status.HTTP_200_OK) + + def _verify_otp(self, request): + serializer = VerifyOTPSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + token = serializer.validated_data["token"] + otp_code = serializer.validated_data["otp_code"].strip() + + try: + phone_number = OTP_SIGNER.unsign(token, max_age=OTP_TTL_SECONDS) + except (BadSignature, SignatureExpired): + return Response({"code": 400, "msg": "Token is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST) + + cached_otp = cache.get(f"otp_code:{phone_number}") + if cached_otp is None or cached_otp != otp_code: + return Response({"code": 400, "msg": "OTP code is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST) + + cache.delete(f"otp_code:{phone_number}") + user, created = User.objects.get_or_create( + phone_number=phone_number, + defaults={ + "username": phone_number, + "email": f"{phone_number}@otp.local", + }, + ) + + return Response( + { + "code": 200, + "msg": "success", + "data": _auth_user_to_data(user), + "token": _issue_token(user), + }, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/celerybeat-schedule b/Modules/Backend/celerybeat-schedule new file mode 100644 index 0000000000000000000000000000000000000000..289f8ae5b6995be5f3f7d421d8b35e0e161db260 GIT binary patch literal 16384 zcmeI(O>5LZ7zgl)rtJ1bDYno;TdN0AE1OoU9z;+mMWo_ED}o2ZY<6nGrddg5gzyVV(fk15O9El7Qj5f zJit7_eSkcGJb*laJb*laJb*laJaEYJfNkz-Hj+oSU>b{-iFfnuJNfqWi>7~j*u>e( zChn%z$NEXjpWcgpqXGvw^xhws5BoD8zI@z1>$=axNS^O>dK;$#|NZfYGn|b89N^F^ zVA@lUw@x;t=icba&v>3JoRB|_1swL~z+ZhI6OoKRUo!ibDBPm&eDK_x=lgf7ld{d& zg*;y!p8Yj>2RpMT>dXhg2twKubW0nizz16Q0y=c;mKn|k%U&`Wys z!RnH7bV?QxuZE)5wO=~FN83D||6b5h>15K1yviDn*DE{}v8>3Ldv!XJN{2l`JuRmy zxK!z}`<^dq%}^}UNww65I_F#IoZivRc1#?}lX~Ll@!X=@EygR?xD+y27b-298)|8& z^wpl8m@gk+-lA8k%^;LPM8_{Z5t2(8)3MTWyAg>XT5C}A5jAh?CiM@x&FE?1)kC3P zsC&v>_==_5x~y2~Xnqw*e)@V~FK$GVSM^1t)|~RE%qnS*j;fyTP_5gEqf5bdtuJhU zBk!B%44Yu=!))I@&+N)`+RxUVo`vQ017kTbM3h$et?!nv|82RTviUjV)4EgB-0Y|! U009U<00Izz00bZafkPqi3k{%fl>h($ literal 0 HcmV?d00001 diff --git a/Modules/Backend/config/__init__.py b/Modules/Backend/config/__init__.py new file mode 100644 index 0000000..53f4ccb --- /dev/null +++ b/Modules/Backend/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/Modules/Backend/config/asgi.py b/Modules/Backend/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/Modules/Backend/config/asgi.py @@ -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() diff --git a/Modules/Backend/config/celery.py b/Modules/Backend/config/celery.py new file mode 100644 index 0000000..3a079f1 --- /dev/null +++ b/Modules/Backend/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +app = Celery("config") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/Modules/Backend/config/failure_contract.py b/Modules/Backend/config/failure_contract.py new file mode 100644 index 0000000..1ecbc68 --- /dev/null +++ b/Modules/Backend/config/failure_contract.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FailureContract: + status: str = "error" + error_code: str = "internal_error" + message: str = "" + source: str = "application" + warnings: list[str] = field(default_factory=list) + retriable: bool = False + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + payload = { + "status": self.status, + "error_code": self.error_code, + "message": self.message, + "source": self.source, + "warnings": list(self.warnings), + "retriable": self.retriable, + } + if self.details: + payload["details"] = self.details + return payload + + +class StructuredServiceError(Exception): + def __init__( + self, + *, + error_code: str, + message: str, + source: str, + warnings: list[str] | None = None, + retriable: bool = False, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.contract = FailureContract( + error_code=error_code, + message=message, + source=source, + warnings=warnings or [], + retriable=retriable, + details=details or {}, + ) + + def to_dict(self) -> dict[str, Any]: + return self.contract.to_dict() diff --git a/Modules/Backend/config/feature.json b/Modules/Backend/config/feature.json new file mode 100644 index 0000000..dbb8f4a --- /dev/null +++ b/Modules/Backend/config/feature.json @@ -0,0 +1,18 @@ +{ + "auth": "auth_access", + "account": "account_management", + "farm_hub": "farm_management", + "access_control": "access_control", + "sensor_catalog": "sensor_catalog", + "dashboard": "farm_dashboard", + "crop_zoning": "crop_zoning", + "plant_simulator": "plant_simulator", + "pest_detection": "pest_detection", + "sensor_7_in_1": "sensor-7-in-1", + "irrigation": "irrigation", + "fertilization": "fertilization", + "farm_ai_assistant": "farm_ai_assistant", + "notifications": "notifications", + "external_api_adapter": "external_api_adapter", + "sensor_external_api": "sensor_external_api" + } diff --git a/Modules/Backend/config/integration_contract.py b/Modules/Backend/config/integration_contract.py new file mode 100644 index 0000000..0c69cb0 --- /dev/null +++ b/Modules/Backend/config/integration_contract.py @@ -0,0 +1,45 @@ +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, + sync_attempted: bool | None = None, + sync_status: str | None = 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 sync_attempted is not None: + meta["sync_attempted"] = sync_attempted + if sync_status is not None: + meta["sync_status"] = sync_status + if notes: + meta["notes"] = notes + return meta diff --git a/Modules/Backend/config/observability.py b/Modules/Backend/config/observability.py new file mode 100644 index 0000000..1ce819f --- /dev/null +++ b/Modules/Backend/config/observability.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import logging +import time +from collections import Counter +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + + +logger = logging.getLogger(__name__) +_request_id_ctx: ContextVar[str | None] = ContextVar("backend_request_id", default=None) +METRICS: Counter[str] = Counter() + + +def set_request_id(request_id: str | None) -> None: + _request_id_ctx.set(request_id) + + +def get_request_id() -> str | None: + return _request_id_ctx.get() + + +def record_metric(name: str, value: int = 1, **tags: Any) -> None: + suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None) + metric_key = f"{name}|{suffix}" if suffix else name + METRICS[metric_key] += value + + +@dataclass +class ClassifiedFailure: + error_code: str + failure_type: str + retriable: bool + + +def classify_exception(exc: Exception) -> ClassifiedFailure: + exc_name = exc.__class__.__name__.lower() + message = str(exc).lower() + if "timeout" in exc_name or "timeout" in message: + return ClassifiedFailure("timeout", "timeout", True) + if "json" in exc_name or "json" in message: + return ClassifiedFailure("parse_error", "parse_error", False) + if "validation" in exc_name or "invalid" in message: + return ClassifiedFailure("validation_failure", "validation_failure", False) + if "connection" in exc_name or "unavailable" in message: + return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True) + return ClassifiedFailure("provider_error", "provider_error", True) + + +def log_event( + *, + level: int, + message: str, + source: str, + provider: str | None, + operation: str, + result_status: str, + duration_ms: float | None = None, + error_code: str | None = None, + **extra: Any, +) -> None: + payload = { + "source": source, + "provider": provider, + "operation": operation, + "result_status": result_status, + "duration_ms": round(duration_ms, 2) if duration_ms is not None else None, + "error_code": error_code, + "request_id": get_request_id(), + } + payload.update({key: value for key, value in extra.items() if value is not None}) + logger.log(level, message, extra={"event": payload}) + + +class observe_operation: + def __init__(self, *, source: str, provider: str | None, operation: str): + self.source = source + self.provider = provider + self.operation = operation + self.started_at = 0.0 + + def __enter__(self): + self.started_at = time.monotonic() + log_event( + level=logging.INFO, + message="backend operation started", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="started", + ) + return self + + def __exit__(self, exc_type, exc, _tb): + duration_ms = (time.monotonic() - self.started_at) * 1000 + if exc is None: + log_event( + level=logging.INFO, + message="backend operation completed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="success", + duration_ms=duration_ms, + ) + record_metric("backend.operation.success", source=self.source, provider=self.provider, operation=self.operation) + return False + + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="backend operation failed", + source=self.source, + provider=self.provider, + operation=self.operation, + result_status="error", + duration_ms=duration_ms, + error_code=failure.error_code, + failure_type=failure.failure_type, + ) + record_metric( + "backend.operation.failure", + source=self.source, + provider=self.provider, + operation=self.operation, + error_code=failure.error_code, + ) + return False diff --git a/Modules/Backend/config/settings.py b/Modules/Backend/config/settings.py new file mode 100644 index 0000000..7db54f8 --- /dev/null +++ b/Modules/Backend/config/settings.py @@ -0,0 +1,294 @@ +import os +from datetime import timedelta +from pathlib import Path + +from dotenv import load_dotenv +from celery.schedules import crontab + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent +LOG_DIR = BASE_DIR / "logs" +LOG_DIR.mkdir(exist_ok=True) + + +def _get_csv_env(name, default=""): + return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] + +SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") +DEBUG = os.environ.get("DEBUG", "0") == "1" +ALLOWED_HOSTS = list( + dict.fromkeys( + _get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0") + + ["web", "backend-web", os.environ.get("HOSTNAME", "")] + ) +) + +AUTH_USER_MODEL = "account.User" + +AUTHENTICATION_BACKENDS = [ + "account.backends.MultiFieldBackend", +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "auth.apps.AuthConfig", + "account.apps.AccountConfig", + "farm_hub.apps.FarmHubConfig", + "device_hub.apps.DeviceHubConfig", + "access_control.apps.AccessControlConfig", + "dashboard", + "crop_health.apps.CropHealthConfig", + "soil.apps.SoilConfig", + "crop_zoning", + "pest_detection", + "water.apps.WaterConfig", + "irrigation", + "yield_harvest.apps.YieldHarvestConfig", + "economic_overview.apps.EconomicOverviewConfig", + "farm_alerts.apps.FarmAlertsConfig", + "fertilization", + "farm_ai_assistant", + "notifications.apps.NotificationsConfig", + "plants.apps.PlantsConfig", + "farmer_calendar.apps.FarmerCalendarConfig", + "farmer_todos.apps.FarmerTodosConfig", + "external_api_adapter.apps.ExternalApiAdapterConfig", + "rest_framework", + "drf_spectacular", + "drf_spectacular_sidecar", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "access_control.middleware.RouteFeatureAccessMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +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", "croplogic"), + "USER": os.environ.get("DB_USER", "croplogic"), + "PASSWORD": os.environ.get("DB_PASSWORD", ""), + "HOST": os.environ.get("DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("DB_PORT", "3306"), + "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" + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.getenv("CACHE_URL", os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")), + "KEY_PREFIX": "croplogic", + } +} + +PEST_DISEASE_RISK_SUMMARY_CACHE_TTL = int(os.getenv("PEST_DISEASE_RISK_SUMMARY_CACHE_TTL", "14400")) +WATER_NEED_PREDICTION_CACHE_TTL = int(os.getenv("WATER_NEED_PREDICTION_CACHE_TTL", "14400")) +SOIL_SUMMARY_CACHE_TTL = int(os.getenv("SOIL_SUMMARY_CACHE_TTL", "14400")) +SOIL_ANOMALIES_CACHE_TTL = int(os.getenv("SOIL_ANOMALIES_CACHE_TTL", "14400")) + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + "access_control.permissions.FeatureAccessPermission", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "CropLogic API", + "DESCRIPTION": "Swagger/OpenAPI documentation for all CropLogic 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"], + "APPEND_COMPONENTS": { + "securitySchemes": { + "SensorExternalApiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Use API key 12345 for sensor external API endpoints.", + } + } + }, + "SWAGGER_UI_SETTINGS": { + "persistAuthorization": True, + }, +} + + +SMS_IR_API_KEY = os.environ.get("SMS_IR_API_KEY", "") +SMS_IR_LINE_NUMBER = int(os.environ.get("SMS_IR_LINE_NUMBER", "300000000000")) + +CORS_ALLOW_ALL_ORIGINS = DEBUG + +USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true" +EXTERNAL_API_TIMEOUT = int(os.getenv("EXTERNAL_API_TIMEOUT", "30")) + +ACCESS_CONTROL_AUTHZ_ENABLED = os.getenv("ACCESS_CONTROL_AUTHZ_ENABLED", "true").lower() == "true" +ACCESS_CONTROL_AUTHZ_BASE_URL = os.getenv( + "ACCESS_CONTROL_AUTHZ_BASE_URL", + "http://croplogic-accsess-opa:8181", +) +ACCESS_CONTROL_AUTHZ_BATCH_PATH = os.getenv("ACCESS_CONTROL_AUTHZ_BATCH_PATH", "/v1/data/croplogic/authz/batch_decision") +ACCESS_CONTROL_AUTHZ_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_TIMEOUT", str(EXTERNAL_API_TIMEOUT))) +ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", "300")) + +EXTERNAL_SERVICES = { + "ai": { + "base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"), + "api_key": os.getenv("AI_SERVICE_API_KEY", ""), + "host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"), + }, + "farm_hub": { + "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), + "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), + "host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""), + }, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=7), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, +} + +CROP_ZONE_CHUNK_AREA_SQM = float(os.getenv("CROP_ZONE_CHUNK_AREA_SQM", "10000")) +CROP_ZONE_TASK_STALE_SECONDS = int(os.getenv("CROP_ZONE_TASK_STALE_SECONDS", "300")) + +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) +NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL) +EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345") +SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345") +FARM_DATA_API_HOST = os.getenv("FARM_DATA_API_HOST", "") +FARM_DATA_API_PORT = os.getenv("FARM_DATA_API_PORT", "") +FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/") +FARM_DATA_API_KEY = os.getenv("FARM_DATA_API_KEY", "") +FARM_DATA_API_TIMEOUT = int(os.getenv("FARM_DATA_API_TIMEOUT", str(EXTERNAL_API_TIMEOUT))) +CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default") +CELERY_TASK_ACKS_LATE = True +CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1")) +CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "120")) +CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "90")) +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = os.getenv("CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP", "true").lower() == "true" +FARM_ALERTS_AI_SYNC_CRON_MINUTE = os.getenv("FARM_ALERTS_AI_SYNC_CRON_MINUTE", "0") +FARM_ALERTS_AI_SYNC_CRON_HOUR = os.getenv("FARM_ALERTS_AI_SYNC_CRON_HOUR", "*") + +CELERY_BEAT_SCHEDULE = { + "sync-farm-alert-trackers": { + "task": "farm_alerts.tasks.sync_farm_alert_trackers", + "schedule": crontab( + minute=FARM_ALERTS_AI_SYNC_CRON_MINUTE, + hour=FARM_ALERTS_AI_SYNC_CRON_HOUR, + ), + } +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "handlers": { + "farm_ai_assistant_file": { + "level": "WARNING", + "class": "logging.FileHandler", + "filename": LOG_DIR / "farm_ai_assistant.log", + "formatter": "standard", + }, + "farm_alerts_file": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": LOG_DIR / "farm_alerts.log", + "formatter": "standard", + }, + "external_api_adapter_file": { + "level": "WARNING", + "class": "logging.FileHandler", + "filename": LOG_DIR / "external_api_adapter.log", + "formatter": "standard", + }, + }, + "loggers": { + "farm_ai_assistant": { + "handlers": ["farm_ai_assistant_file"], + "level": "WARNING", + "propagate": False, + }, + "farm_alerts": { + "handlers": ["farm_alerts_file"], + "level": "INFO", + "propagate": False, + }, + "external_api_adapter": { + "handlers": ["external_api_adapter_file"], + "level": "WARNING", + "propagate": False, + }, + }, +} diff --git a/Modules/Backend/config/swagger.py b/Modules/Backend/config/swagger.py new file mode 100644 index 0000000..56a7f04 --- /dev/null +++ b/Modules/Backend/config/swagger.py @@ -0,0 +1,55 @@ +from rest_framework import serializers + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, inline_serializer + + +FARM_UUID_DEFAULT = "11111111-1111-1111-1111-111111111111" + + +class AuthTokenSerializer(serializers.Serializer): + token = serializers.CharField() + + +def code_response(name, data=None, token=False, extra_fields=None): + fields = { + "code": serializers.IntegerField(), + "msg": serializers.CharField(), + } + if data is not None: + fields["data"] = data + if token: + fields["token"] = serializers.CharField() + if extra_fields: + fields.update(extra_fields) + return inline_serializer(name=name, fields=fields) + + +def status_response(name, data=None): + fields = { + "status": serializers.CharField(default="success"), + } + if data is not None: + fields["data"] = data + return inline_serializer(name=name, fields=fields) + + +def farm_uuid_query_param(required=False, description="UUID of the farm."): + return OpenApiParameter( + name="farm_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=required, + description=description, + default=FARM_UUID_DEFAULT, + ) + + +def sensor_uuid_query_param(required=False, description="Optional sensor UUID."): + return OpenApiParameter( + name="sensor_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=required, + description=description, + ) diff --git a/Modules/Backend/config/urls.py b/Modules/Backend/config/urls.py new file mode 100644 index 0000000..44f71ca --- /dev/null +++ b/Modules/Backend/config/urls.py @@ -0,0 +1,44 @@ +from django.contrib import admin +from django.urls import include, path +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +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"), + path("api/auth/", include("auth.urls")), + path("api/account/", include("account.urls")), + path("api/farm-hub/", include("farm_hub.urls")), + path("api/access-control/", include("access_control.urls")), + path("api/device-hub/", include("device_hub.urls")), + path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")), + path("api/farm-dashboard-config/", include("dashboard.urls_config")), + path("api/farm-dashboard/", include("dashboard.urls")), + path("api/crop-health/", include("crop_health.urls")), + path("api/soil/", include("soil.urls")), + + path("api/crop-zoning/", include("crop_zoning.urls")), + # path("api/yield-harvest/", include("yield_harvest.urls")), + path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")), + + path("api/pest-detection/", include("pest_detection.urls")), + path("api/pest-disease/", include("pest_detection.pest_disease_urls")), + path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")), + path("api/sensors/", include("device_hub.comparison_urls")), + path("api/irrigation/", include("irrigation.urls")), + + path("api/weather/", include("water.weather_urls")), + path("api/water/", include("water.urls")), + path("api/economy/", include("economic_overview.urls")), + + path("api/fertilization/", include("fertilization.urls")), + path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")), + path("api/notifications/", include("notifications.urls")), + path("api/farm-alerts/", include("farm_alerts.urls")), + path("api/plants/", include("plants.urls")), + path("api/events/", include("farmer_calendar.urls")), + path("api/farmer-todos/", include("farmer_todos.urls")), + + path("api/sensor-external-api/", include("device_hub.sensor_external_api_urls")), +] diff --git a/Modules/Backend/config/wsgi.py b/Modules/Backend/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/Modules/Backend/config/wsgi.py @@ -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() diff --git a/Modules/Backend/crop_health/__init__.py b/Modules/Backend/crop_health/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/crop_health/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/crop_health/apps.py b/Modules/Backend/crop_health/apps.py new file mode 100644 index 0000000..ef96ce3 --- /dev/null +++ b/Modules/Backend/crop_health/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CropHealthConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "crop_health" + verbose_name = "Crop Health" diff --git a/Modules/Backend/crop_health/mock_data.py b/Modules/Backend/crop_health/mock_data.py new file mode 100644 index 0000000..95ad089 --- /dev/null +++ b/Modules/Backend/crop_health/mock_data.py @@ -0,0 +1,27 @@ +FARM_HEALTH_SCORE = { + "id": "farm_health_score", + "title": "امتیاز سلامت مزرعه", + "subtitle": "تحلیل هوشمند", + "stats": "87%", + "avatarColor": "success", + "avatarIcon": "tabler-heartbeat", + "chipText": "خوب", + "chipColor": "success", +} + + +NDVI_HEALTH_CARD = { + "ndviIndex": 0.78, + "mean_ndvi": 0.78, + "ndvi_map": { + "type": "FeatureCollection", + "features": [], + }, + "vegetation_health_class": "Healthy", + "observation_date": "2026-04-10", + "satellite_source": "sentinel-2", + "healthData": [ + {"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"}, + {"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"}, + ], +} diff --git a/Modules/Backend/crop_health/models.py b/Modules/Backend/crop_health/models.py new file mode 100644 index 0000000..beeb308 --- /dev/null +++ b/Modules/Backend/crop_health/models.py @@ -0,0 +1,2 @@ +from django.db import models + diff --git a/Modules/Backend/crop_health/serializers.py b/Modules/Backend/crop_health/serializers.py new file mode 100644 index 0000000..97dea2c --- /dev/null +++ b/Modules/Backend/crop_health/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + + +class CropHealthRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(help_text="UUID مزرعه برای دریافت تحلیل سلامت گیاه.") + + +class HealthDataItemSerializer(serializers.Serializer): + title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان آیتم سلامت.") + value = serializers.JSONField(required=False, help_text="مقدار آیتم سلامت؛ می‌تواند عدد، متن یا ساختار JSON باشد.") + color = serializers.CharField(required=False, allow_blank=True, help_text="رنگ نمایشی آیتم سلامت.") + icon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون نمایشی آیتم سلامت.") + + +class NdviHealthCardSerializer(serializers.Serializer): + ndviIndex = serializers.FloatField(required=False, help_text="شاخص NDVI نرمال‌شده برای مزرعه.") + mean_ndvi = serializers.FloatField(required=False, help_text="میانگین NDVI محاسبه‌شده.") + ndvi_map = serializers.JSONField(required=False, help_text="لایه یا متادیتای نقشه NDVI.") + vegetation_health_class = serializers.CharField(required=False, allow_blank=True, help_text="کلاس سلامت پوشش گیاهی.") + observation_date = serializers.DateField(required=False, help_text="تاریخ مشاهده ماهواره‌ای.") + satellite_source = serializers.CharField(required=False, allow_blank=True, help_text="منبع تصویر ماهواره‌ای.") + healthData = HealthDataItemSerializer(many=True, required=False) + + +class FarmHealthScoreSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + stats = serializers.CharField(required=False, allow_blank=True) + avatarColor = serializers.CharField(required=False, allow_blank=True) + avatarIcon = serializers.CharField(required=False, allow_blank=True) + chipText = serializers.CharField(required=False, allow_blank=True) + chipColor = serializers.CharField(required=False, allow_blank=True) + + +class CropHealthSummarySerializer(serializers.Serializer): + ndviHealthCard = NdviHealthCardSerializer(required=False) + farmHealthScore = FarmHealthScoreSerializer(required=False) diff --git a/Modules/Backend/crop_health/services.py b/Modules/Backend/crop_health/services.py new file mode 100644 index 0000000..ef79113 --- /dev/null +++ b/Modules/Backend/crop_health/services.py @@ -0,0 +1,10 @@ +from copy import deepcopy + +from .mock_data import FARM_HEALTH_SCORE, NDVI_HEALTH_CARD + + +def get_crop_health_summary_data(farm=None): + return { + "ndviHealthCard": deepcopy(NDVI_HEALTH_CARD), + "farmHealthScore": deepcopy(FARM_HEALTH_SCORE), + } diff --git a/Modules/Backend/crop_health/tests.py b/Modules/Backend/crop_health/tests.py new file mode 100644 index 0000000..cb96139 --- /dev/null +++ b/Modules/Backend/crop_health/tests.py @@ -0,0 +1,110 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from unittest.mock import patch + +from .views import CropHealthSummaryView, NdviHealthView + + +class NdviHealthViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="ndvi-user", + password="secret123", + email="ndvi@example.com", + phone_number="09120000020", + ) + self.other_user = get_user_model().objects.create_user( + username="ndvi-other-user", + password="secret123", + email="ndvi-other@example.com", + phone_number="09120000021", + ) + self.farm_type = FarmType.objects.create(name="NDVI Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="NDVI Farm", + ) + + @patch("crop_health.views.external_api_request") + def test_post_ndvi_health_returns_expected_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"ndviIndex": 0.78, "mean_ndvi": 0.78, "vegetation_health_class": "Healthy", "satellite_source": "sentinel-2"}}}, + ) + + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "success") + self.assertEqual(response.data["data"]["ndviIndex"], 0.78) + self.assertEqual(response.data["data"]["mean_ndvi"], 0.78) + self.assertEqual(response.data["data"]["vegetation_health_class"], "Healthy") + self.assertEqual(response.data["data"]["satellite_source"], "sentinel-2") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soil-data/ndvi-health/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_post_ndvi_health_requires_farm_uuid(self): + request = self.factory.post("/api/crop-health/ndvi-health/", {}, format="json") + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("farm_uuid", response.data) + + def test_post_ndvi_health_returns_404_for_missing_farm(self): + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": "11111111-1111-1111-1111-111111111111"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + def test_post_ndvi_health_does_not_expose_other_users_farm(self): + request = self.factory.post( + "/api/crop-health/ndvi-health/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.other_user) + + response = NdviHealthView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + def test_crop_health_routes_exist(self): + self.assertIs(resolve("/api/crop-health/ndvi-health/").func.view_class, NdviHealthView) + self.assertIs(resolve("/api/crop-health/summary/").func.view_class, CropHealthSummaryView) + + def test_removed_soil_health_alias_routes_no_longer_resolve(self): + with self.assertRaises(Resolver404): + resolve("/api/soil/health/ndvi-health/") + with self.assertRaises(Resolver404): + resolve("/api/soil/health/summary/") + with self.assertRaises(Resolver404): + resolve("/api/soil-data/ndvi-health/") diff --git a/Modules/Backend/crop_health/urls.py b/Modules/Backend/crop_health/urls.py new file mode 100644 index 0000000..68ddf6d --- /dev/null +++ b/Modules/Backend/crop_health/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import CropHealthSummaryView, NdviHealthView + +urlpatterns = [ + path("ndvi-health/", NdviHealthView.as_view(), name="crop-health-ndvi-health"), + path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"), +] diff --git a/Modules/Backend/crop_health/views.py b/Modules/Backend/crop_health/views.py new file mode 100644 index 0000000..3c10098 --- /dev/null +++ b/Modules/Backend/crop_health/views.py @@ -0,0 +1,82 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from config.swagger import farm_uuid_query_param, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub + +from .serializers import CropHealthRequestSerializer, CropHealthSummarySerializer, NdviHealthCardSerializer +from .services import get_crop_health_summary_data + + +class CropHealthSummaryView(APIView): + @extend_schema( + tags=["Crop Health"], + parameters=[ + farm_uuid_query_param(required=False, description="UUID of the farm for crop health data."), + ], + responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())}, + ) + def get(self, request): + return Response( + {"status": "success", "data": get_crop_health_summary_data()}, + status=status.HTTP_200_OK, + ) + + +class NdviHealthView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @extend_schema( + tags=["Crop Health"], + request=CropHealthRequestSerializer, + responses={200: status_response("NdviHealthResponse", data=NdviHealthCardSerializer())}, + ) + def post(self, request): + serializer = CropHealthRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + farm = FarmHub.objects.get(farm_uuid=serializer.validated_data["farm_uuid"], owner=request.user) + except FarmHub.DoesNotExist: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + adapter_response = external_api_request( + "ai", + "/api/soil-data/ndvi-health/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + data = self._extract_result(adapter_response.data) + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/crop_zoning/CROP_ZONING_CODE_LOGIC.md b/Modules/Backend/crop_zoning/CROP_ZONING_CODE_LOGIC.md new file mode 100644 index 0000000..26493c0 --- /dev/null +++ b/Modules/Backend/crop_zoning/CROP_ZONING_CODE_LOGIC.md @@ -0,0 +1,883 @@ +# Crop Zoning Code Logic + +این فایل یک توضیح کامل و شفاف از منطق سه فایل زیر است: + +- `crop_zoning/views.py` +- `crop_zoning/services.py` +- `crop_zoning/tests.py` + +هدف این داکیومنت این است که بدون نیاز به خواندن مستقیم کد، بتوان فهمید هر endpoint چه می‌کند، داده‌ها چگونه ساخته می‌شوند، taskها چگونه مدیریت می‌شوند، و تست‌ها چه رفتارهایی را پوشش می‌دهند. + +--- + +## تصویر کلی ماژول + +ماژول `crop_zoning` برای این ساخته شده که: + +1. یک polygon مربوط به زمین را دریافت یا پیدا کند. +2. آن را به چند zone مربعی تقسیم کند. +3. برای هر zone داده اولیه تولید کند. +4. برای هر zone یک task پردازش جداگانه ثبت کند. +5. خروجی مناسب برای فرانت برگرداند تا هم وضعیت پردازش را بداند و هم zoneها را روی نقشه نمایش دهد. + +این ماژول دو نوع داده برای zoneها دارد: + +- داده اولیه و rule-based که سریع ساخته می‌شود و برای خالی نبودن UI استفاده می‌شود. +- داده تحلیلی که بعدا از طریق task و داده خاک تکمیل می‌شود. + +--- + +## منطق `crop_zoning/views.py` + +فایل `views.py` فقط لایه HTTP است. +یعنی کار اصلی را خودش انجام نمی‌دهد، بلکه: + +- ورودی request را می‌خواند +- آن را validate می‌کند یا به serviceها می‌سپارد +- خروجی مناسب را به صورت JSON response برمی‌گرداند + +### 1) `AreaView` + +این مهم‌ترین endpoint ماژول است. + +### کار این view + +- `farm_uuid` را از query params می‌گیرد. +- `page` و `page_size` را هم از query params می‌گیرد. +- از service می‌خواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند. +- اگر zoneها وجود نداشته باشند، ساخته می‌شوند. +- اگر taskهای پردازش لازم باشند، dispatch می‌شوند. +- در نهایت خروجی area + zoneهای همان صفحه + اطلاعات pagination را برمی‌گرداند. + +### ورودی‌های `AreaView` + +- `farm_uuid`: اجباری +- `page`: اختیاری، پیش‌فرض `1` +- `page_size`: اختیاری، پیش‌فرض `10` + +### خروجی `AreaView` + +خروجی سه بخش مهم دارد: + +- `task`: وضعیت پردازش کل area +- `area`: polygon اصلی زمین +- `zones`: فقط zoneهای مربوط به همان صفحه +- `pagination`: اطلاعات صفحه‌بندی zoneها + +### مدیریت خطا در `AreaView` + +اگر هر کدام از این موارد رخ بدهد، خطای `400` داده می‌شود: + +- `farm_uuid` ارسال نشده باشد +- `farm_uuid` معتبر نباشد یا farm پیدا نشود +- `page` نامعتبر باشد +- `page_size` نامعتبر باشد + +اگر تنظیمات سمت سرور مشکل داشته باشند، خطای `500` داده می‌شود. + +--- + +### 2) `ProductsView` + +این endpoint لیست محصولات قابل کشت را برمی‌گرداند. + +### کار این view + +- از service می‌خواهد محصولات پیش‌فرض داخل دیتابیس sync شوند. +- سپس لیست محصولات را به فرمت مناسب فرانت برمی‌گرداند. + +این view ساده است و منطق تحلیلی ندارد. + +--- + +### 3) `ZonesInitialView` + +این view برای ساخت zoneها از روی یک polygon ورودی استفاده می‌شود. + +### کار این view + +- polygon را از یکی از این کلیدها می‌گیرد: + - `area` + - `area_geojson` + - `boundary` +- اگر هیچ‌کدام نباشد، از area پیش‌فرض mock استفاده می‌کند. +- در صورت ارسال، `cell_side_km` را هم می‌گیرد. +- service را صدا می‌زند تا area و zoneها ساخته شوند. +- response اولیه zoneها را برمی‌گرداند. + +### تفاوت با `AreaView` + +- `AreaView` بر اساس `farm_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند. +- `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است. + +--- + +### 4) `ZonesWaterNeedView` + +این view لایه نیاز آبی zoneها را برمی‌گرداند. + +### کار این view + +- از request، `zoneIds` را می‌گیرد. +- service را صدا می‌زند. +- برای هر zone، level و value و color مربوط به آب را برمی‌گرداند. + +--- + +### 5) `ZonesSoilQualityView` + +این view لایه کیفیت خاک zoneها را برمی‌گرداند. + +### خروجی اصلی + +برای هر zone: + +- `level` +- `score` +- `color` + +--- + +### 6) `ZonesCultivationRiskView` + +این view لایه ریسک کشت zoneها را برمی‌گرداند. + +### خروجی اصلی + +برای هر zone: + +- `level` +- `color` + +--- + +### 7) `ZoneDetailsView` + +این endpoint جزئیات یک zone را برمی‌گرداند. + +### کار این view + +- `zone_id` را از URL می‌گیرد. +- جزئیات recommendation آن zone را از service می‌خواند. +- اگر zone پیدا نشود، `404` برمی‌گرداند. + +### خروجی اصلی + +- crop پیشنهادی +- درصد تطابق +- نیاز آبی +- سود تخمینی +- reason +- criteria +- مساحت zone + +--- + +## منطق `crop_zoning/services.py` + +این فایل قلب اصلی ماژول است. +بیشتر منطق واقعی اینجا پیاده‌سازی شده. + +برای فهم بهتر، این فایل را می‌توان به 8 بخش تقسیم کرد. + +--- + +## بخش 1: تنظیمات و utilityهای اولیه + +### ثابت‌ها + +چند constant اصلی در ابتدای فایل تعریف شده‌اند: + +- `DEFAULT_CELL_SIDE_KM`: اندازه پیش‌فرض ضلع هر zone +- `DEFAULT_ZONE_PAGE_SIZE`: تعداد پیش‌فرض zoneها در هر صفحه response +- `RULE_BASED_ALGORITHM`: نام الگوریتم rule-based +- `RULE_BASED_PRODUCTS`: داده اولیه محصولات و اطلاعات نمایشی آنها + +### `get_default_cell_side_km()` + +این تابع اندازه پیش‌فرض ضلع هر zone را مشخص می‌کند. + +اولویت‌ها: + +1. اگر `CROP_ZONE_CELL_SIDE_KM` در settings وجود داشته باشد، همان استفاده می‌شود. +2. اگر نبود، از `CROP_ZONE_CHUNK_AREA_SQM` استفاده می‌کند و از روی آن ضلع مربع را حساب می‌کند. +3. اگر هیچ‌کدام نباشند، از `DEFAULT_CELL_SIDE_KM` استفاده می‌شود. + +### `get_task_stale_seconds()` + +این تابع مشخص می‌کند بعد از چند ثانیه یک task ممکن است stale محسوب شود. +یعنی اگر task گیر کرده باشد، دوباره dispatch شود. + +### `get_cell_side_km(cell_side_km=None)` + +اگر کاربر اندازه zone را داده باشد، آن را validate می‌کند. +اگر نداده باشد، مقدار پیش‌فرض را برمی‌گرداند. + +### `get_chunk_area_sqm(cell_side_km=None)` + +مساحت zone را از روی ضلع آن حساب می‌کند: + +- ضلع بر حسب کیلومتر دریافت می‌شود +- به متر تبدیل می‌شود +- مربع آن به عنوان مساحت zone برگردانده می‌شود + +### `parse_positive_int(...)` + +برای validate کردن پارامترهای عددی مثبت استفاده می‌شود. +الان برای `page` و `page_size` استفاده می‌شود. + +### `get_zone_page_request_params(query_params)` + +این تابع پارامترهای pagination را از query params می‌گیرد: + +- `page` +- `page_size` + +اگر ارسال نشده باشند، از default استفاده می‌کند. +اگر نامعتبر باشند، `ValueError` می‌دهد. + +--- + +## بخش 2: آماده‌سازی polygon و محاسبات هندسی + +این بخش مسئول کار با GeoJSON و polygon است. + +### `get_default_area_feature()` + +یک area پیش‌فرض از داده mock برمی‌گرداند. + +### `normalize_area_feature(area_feature)` + +این تابع ورودی area را normalize می‌کند تا همیشه ساختار `Feature` داشته باشد. + +### کارهای این تابع + +- بررسی می‌کند ورودی null نباشد +- بررسی می‌کند ورودی dict باشد +- اگر ورودی از نوع `Feature` نباشد، آن را به `Feature` تبدیل می‌کند +- بررسی می‌کند geometry از نوع `Polygon` باشد +- بررسی می‌کند polygon حداقل 4 نقطه داشته باشد + +### `get_polygon_ring(area_feature)` + +حلقه اصلی polygon را استخراج می‌کند. + +### `polygon_area_sqm(ring)` + +مساحت polygon را به متر مربع حساب می‌کند. +برای این کار نقاط جغرافیایی را به مختصات مسطح تقریبی تبدیل می‌کند و فرمول shoelace را اجرا می‌کند. + +### `normalize_points(ring)` + +اگر آخر polygon با نقطه اول بسته شده باشد، نقطه تکراری آخر را حذف می‌کند. + +### `calculate_center(points)` + +مرکز تقریبی polygon یا مربع را از میانگین نقاط حساب می‌کند. + +### `get_bbox(points)` + +کمینه و بیشینه طول و عرض جغرافیایی را برمی‌گرداند تا محدوده کلی polygon مشخص شود. + +### `meters_to_latitude_delta(meters)` و `meters_to_longitude_delta(meters, latitude)` + +این دو تابع فاصله متر را به اختلاف latitude و longitude تبدیل می‌کنند. +برای ساخت grid مربعی از این دو تابع استفاده می‌شود. + +--- + +## بخش 3: تشخیص برخورد polygon و cell + +این بخش مشخص می‌کند که آیا یک مربع grid واقعا با polygon زمین برخورد دارد یا نه. + +### `point_in_polygon(point, polygon_points)` + +چک می‌کند یک نقطه داخل polygon هست یا نه. + +### `_orientation`, `_on_segment`, `segments_intersect` + +این توابع utilityهای هندسی برای تشخیص برخورد دو خط هستند. + +### `rectangle_contains_point(point, cell_points)` + +چک می‌کند یک نقطه داخل مربع cell قرار دارد یا نه. + +### `polygon_intersects_cell(polygon_points, cell_points)` + +این مهم‌ترین تابع تقاطع است. +اگر یکی از شرایط زیر برقرار باشد، cell معتبر در نظر گرفته می‌شود: + +- مرکز cell داخل polygon باشد +- یکی از گوشه‌های cell داخل polygon باشد +- یکی از نقاط polygon داخل cell باشد +- یکی از اضلاع polygon با اضلاع cell برخورد داشته باشد + +نتیجه: فقط مربع‌هایی zone می‌شوند که واقعا با زمین هم‌پوشانی داشته باشند. + +--- + +## بخش 4: ساخت zoneها از روی area + +### `build_square_points(...)` + +چهار گوشه یک مربع را از روی مرزهای آن می‌سازد. + +### `build_zone_square(area_points, center, zone_area_sqm)` + +اگر area خیلی کوچک باشد یا zoneی تولید نشود، یک مربع fallback حول center area ساخته می‌شود. + +### `split_area_into_zones(area_feature, cell_side_km=None)` + +این تابع مهم‌ترین بخش ساخت zoneها است. + +### مراحل اجرای آن + +1. polygon area را می‌گیرد. +2. center و bbox و total area را حساب می‌کند. +3. اندازه ضلع zone را مشخص می‌کند. +4. روی bbox یک grid مربعی می‌سازد. +5. هر cell را با `polygon_intersects_cell` بررسی می‌کند. +6. اگر cell با polygon تقاطع داشته باشد، یک zone جدید می‌سازد. +7. برای هر zone این داده‌ها تولید می‌شود: + - `zone_id` + - `geometry` + - `points` + - `center` + - `area_sqm` + - `area_hectares` + - `sequence` +8. اگر هیچ zoneی ساخته نشود، یک zone fallback می‌سازد. +9. در نهایت area summary و لیست zoneها را برمی‌گرداند. + +### نکته مهم + +در این پروژه zoneها grid-based هستند، نه بر اساس تقسیم واقعی shape زمین. +یعنی ابتدا grid مربعی ساخته می‌شود و بعد فقط مربع‌هایی که با زمین برخورد دارند نگه داشته می‌شوند. + +--- + +## بخش 5: تولید recommendation و لایه‌های تحلیلی + +این بخش داده پیشنهادی هر zone را تولید می‌کند. + +### `build_rule_based_zone_metrics(index, coords)` + +این تابع بدون نیاز به API خارجی، برای هر zone یک recommendation اولیه می‌سازد. + +### هدف آن + +وقتی zone تازه ساخته می‌شود، فرانت از همان ابتدا داده خالی نداشته باشد. + +### خروجی آن + +- `recommended_crop` +- `match_percent` +- `water_need_level` +- `water_need_value` +- `soil_quality_score` +- `soil_level` +- `cultivation_risk_level` +- `estimated_profit` +- `reason` +- `criteria` + +این داده‌ها از روی مختصات zone و `sequence` به صورت deterministic ساخته می‌شوند. + +### `build_initial_zone_payload(zone)` + +خروجی سبک و اولیه برای endpoint ساخت zoneها تولید می‌کند. + +### `build_area_zone_payload(zone)` + +خروجی کامل‌تر برای `AreaView` تولید می‌کند و این بخش‌ها را شامل می‌شود: + +- geometry +- center +- area +- processing status +- crop recommendation +- water layer +- soil layer +- risk layer + +### `persist_zone_analysis_metrics(zone, metrics)` + +metrics را داخل مدل‌های مختلف ذخیره می‌کند: + +- recommendation +- criteria +- water need layer +- soil quality layer +- cultivation risk layer + +### `ensure_rule_based_zone_data(zone, force=False)` + +اگر zone هنوز recommendation نداشته باشد، با rule-based data آن را پر می‌کند. + +--- + +## بخش 6: تحلیل خاک واقعی و ذخیره نتیجه + +### `_get_level_color_map(...)` + +رنگ هر level را برای سه لایه water / soil / risk برمی‌گرداند. + +### `_pick_level(...)` + +از روی score مشخص می‌کند level برابر `low` یا `medium` یا `high` است. + +### `_format_range(...)` + +برای ساخت رشته‌هایی مثل `3000-4000 m³/ha` استفاده می‌شود. + +### `_derive_analysis_metrics(depths)` + +این تابع از داده depthهای خاک، recommendation نهایی را می‌سازد. + +### ورودی آن + +آرایه‌ای از depthها که از سرویس خارجی خاک می‌آید. + +### محاسبات مهم آن + +از میانگین این فیلدها استفاده می‌کند: + +- `phh2o` +- `soc` +- `clay` +- `nitrogen` +- `wv0033` + +بعد از اینها محاسبه می‌شود: + +- کیفیت خاک +- نیاز آبی +- ریسک کشت +- محصول پیشنهادی +- درصد تطابق +- reason +- criteria + +### `fetch_soil_data_for_zone(zone)` + +برای یک zone به سرویس خارجی AI درخواست می‌زند و داده خاک می‌گیرد. + +### payload ارسالی + +- longitude +- latitude +- geometry zone +- center +- area + +### `analyze_and_store_zone_soil_data(zone_id)` + +این تابع منطق اصلی پردازش هر zone در worker است. + +### مراحل آن + +1. zone از دیتابیس خوانده می‌شود. +2. اگر قبلا کامل شده باشد، دوباره کاری نمی‌کند. +3. status روی `processing` می‌رود. +4. از API خارجی داده خاک می‌گیرد. +5. depthها را استخراج می‌کند. +6. recommendation واقعی‌تر را از روی خاک می‌سازد. +7. نتیجه را داخل مدل‌های analysis و recommendation ذخیره می‌کند. +8. status را `completed` می‌کند. +9. اگر هر خطایی رخ دهد، status روی `failed` می‌رود و متن خطا ذخیره می‌شود. + +--- + +## بخش 7: مدیریت taskهای zone + +چون هر zone جداگانه پردازش می‌شود، باید taskها مدیریت شوند. + +### `_get_stale_zone_ids(zones)` + +این تابع zoneهایی را پیدا می‌کند که task آنها stale شده است. + +### چه zoneهایی stale محسوب می‌شوند؟ + +- zone کامل نشده باشد +- task_id داشته باشد +- task خیلی قدیمی شده باشد +- یا task_id آن با task یک zone completed مشترک باشد +- یا state task در celery یکی از stateهای نامعتبر برای ادامه باشد + +### `dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None, force=False)` + +این تابع برای zoneهای انتخاب‌شده task celery ثبت می‌کند. + +### رفتار آن + +- zoneهای completed را رد می‌کند +- اگر zone pending/processing باشد و task_id معتبر داشته باشد، دوباره dispatch نمی‌کند مگر `force=True` +- برای هر zone یک task جدا ثبت می‌کند +- اگر celery broker در دسترس نباشد، باز هم یک `task_id` محلی تولید می‌کند +- متن خطا را در `processing_error` ذخیره می‌کند + +### اهمیت این طراحی + +این باعث می‌شود: + +- هر zone مستقل پردازش شود +- fail شدن یک zone بقیه را متوقف نکند +- فرانت بتواند وضعیت هر zone را جدا ببیند + +--- + +## بخش 8: ساخت area، بازیابی area و ساخت payload response + +### `create_missing_zones_for_area(crop_area)` + +اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را می‌سازد. + +### `get_farm_for_uuid(farm_uuid)` + +اعتبارسنجی می‌کند که: + +- `farm_uuid` ارسال شده باشد +- farm واقعا در دیتابیس وجود داشته باشد + +### `ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None)` + +این یکی از مهم‌ترین توابع کل فایل است. + +### منطق آن + +1. sensor را پیدا می‌کند. +2. آخرین area مربوط به آن sensor را می‌گیرد. +3. اگر area وجود نداشته باشد: + - area پیش‌فرض یا area ورودی را می‌گیرد + - area و zoneها را می‌سازد + - taskها را dispatch می‌کند +4. اگر area وجود داشته باشد: + - مطمئن می‌شود zoneها وجود دارند + - برای هر zone، rule-based data را در صورت نبود ایجاد می‌کند + - zoneهای stale را پیدا می‌کند + - zoneهای لازم را دوباره dispatch می‌کند + - area تازه از دیتابیس خوانده می‌شود و برگردانده می‌شود + +### نتیجه این تابع + +وقتی `AreaView` این تابع را صدا می‌زند، همیشه یک area آماده برای نمایش و پردازش دارد. + +### `create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None)` + +این تابع area جدید را می‌سازد. + +### مراحل آن + +1. productها sync می‌شوند. +2. area normalize می‌شود. +3. area به zoneها تقسیم می‌شود. +4. داخل transaction: + - یک `CropArea` ساخته می‌شود + - همه `CropZone`ها bulk create می‌شوند +5. zoneها دوباره از دیتابیس خوانده می‌شوند +6. rule-based data برای هر zone ساخته می‌شود +7. taskهای پردازش dispatch می‌شوند +8. area و zones برگردانده می‌شوند + +### `_zones_queryset(zone_ids=None)` + +یک queryset آماده برمی‌گرداند که relationهای لازم را از قبل load می‌کند: + +- recommendation +- product +- criteria +- water layer +- soil layer +- risk layer + +این باعث می‌شود responseسازی سریع‌تر و با query کمتر انجام شود. + +### `get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE)` + +این تابع خروجی نهایی `AreaView` را می‌سازد. + +### کارهای این تابع + +1. area را پیدا می‌کند. +2. وضعیت همه zoneها را می‌خواند. +3. تعداد completed / pending / processing / failed را حساب می‌کند. +4. `task.status` را تعیین می‌کند. +5. `stage` و `stage_label` را تعیین می‌کند. +6. درصد پیشرفت را حساب می‌کند. +7. zoneهای همان صفحه را با slicing برمی‌دارد. +8. `pagination` را می‌سازد. +9. payload نهایی را برمی‌گرداند. + +### منطق `task.status` + +- اگر zone failed داشته باشیم: `FAILURE` +- اگر همه complete باشند: `SUCCESS` +- اگر بخشی complete یا processing باشند: `PROCESSING` +- در غیر این صورت: `PENDING` + +### منطق pagination + +- `page` و `page_size` از request گرفته می‌شوند +- `total_pages` از تقسیم تعداد کل zoneها بر `page_size` محاسبه می‌شود +- فقط zoneهای همان بازه برگردانده می‌شوند +- اطلاعات page فعلی، تعداد صفحات و وجود صفحه قبل/بعد در body قرار می‌گیرد + +### `get_initial_zones_payload(crop_area)` + +payload ساده‌تر برای endpoint اولیه zoneها می‌سازد. + +### `get_water_need_payload(zone_ids=None)` + +خروجی لایه نیاز آبی را برمی‌گرداند. + +### `get_soil_quality_payload(zone_ids=None)` + +خروجی لایه کیفیت خاک را برمی‌گرداند. + +### `get_cultivation_risk_payload(zone_ids=None)` + +خروجی لایه ریسک کشت را برمی‌گرداند. + +### `get_zone_details_payload(zone_id)` + +خروجی دیتیل یک zone را می‌سازد. + +--- + +## جریان کامل اجرای `GET /api/crop-zoning/area/` + +اگر بخواهیم کل flow را از ابتدا تا انتها خیلی ساده توضیح بدهیم: + +1. فرانت `farm_uuid` و احتمالا `page` و `page_size` را می‌فرستد. +2. `AreaView` پارامترها را می‌خواند. +3. `ensure_latest_area_ready_for_processing` اجرا می‌شود. +4. اگر area وجود نداشته باشد، area و zoneها ساخته می‌شوند. +5. اگر zoneها ناقص باشند، کامل می‌شوند. +6. اگر recommendation اولیه نباشد، ساخته می‌شود. +7. اگر taskهای لازم وجود نداشته باشند یا stale باشند، dispatch می‌شوند. +8. `get_latest_area_payload` اجرا می‌شود. +9. وضعیت کلی task و zoneهای صفحه فعلی ساخته می‌شود. +10. response نهایی به فرانت برمی‌گردد. + +--- + +## منطق `crop_zoning/tests.py` + +این فایل تست رفتار کلیدی API را پوشش می‌دهد. + +تست‌ها با `Django TestCase` و `APIRequestFactory` نوشته شده‌اند. + +### تنظیمات مشترک تست‌ها + +در تست‌ها از این تنظیمات استفاده شده: + +- `USE_EXTERNAL_API_MOCK=True` +- `CROP_ZONE_CHUNK_AREA_SQM=200000` + +هدف این است که: + +- وابستگی به API خارجی واقعی حذف شود +- zoneها با اندازه مشخص و قابل پیش‌بینی ساخته شوند + +--- + +## کلاس `ZonesInitialViewTests` + +### `test_post_accepts_area_geojson_alias` + +این تست بررسی می‌کند که اگر polygon با کلید `area_geojson` ارسال شود: + +- endpoint آن را قبول کند +- پاسخ `200` بدهد +- zone ساخته شود +- تعداد zoneهای خروجی با `zone_count` یکسان باشد + +این تست در عمل alias بودن `area_geojson` را validate می‌کند. + +--- + +## کلاس `AreaViewTests` + +این کلاس رفتارهای اصلی `AreaView` را تست می‌کند. + +### `setUp` + +در شروع هر تست: + +- یک user ساخته می‌شود +- یک sensor برای آن user ساخته می‌شود +- `APIRequestFactory` آماده می‌شود + +### `_create_area(...)` + +یک helper برای ساخت سریع `CropArea` در تست‌ها است. + +### `_request()` + +یک request استاندارد برای `AreaView` با `farm_uuid` معتبر می‌سازد. + +### `_request_with_pagination(page, page_size)` + +یک request برای تست pagination می‌سازد. + +--- + +### تست‌های اصلی `AreaView` + +#### `test_get_requires_farm_uuid` + +بررسی می‌کند اگر `farm_uuid` ارسال نشود، پاسخ `400` برگردد. + +#### `test_get_returns_pending_task_status_until_all_zones_complete` + +بررسی می‌کند اگر zoneها pending و processing باشند: + +- status کلی `PROCESSING` باشد +- area برگردد +- zoneها در response باشند +- فیلد `processing_status` برای zone موجود باشد + +#### `test_get_returns_area_when_all_tasks_complete` + +بررسی می‌کند وقتی همه zoneها complete باشند: + +- status کلی `SUCCESS` باشد +- zoneها برگردند +- فیلدهای recommendation و layerها موجود باشند + +#### `test_get_returns_paginated_zones` + +تست جدید pagination است. + +بررسی می‌کند که: + +- با `page=2` و `page_size=1` +- فقط zone دوم برگردد +- اطلاعات pagination درست باشد +- `total_pages`, `has_next`, `has_previous` درست باشند + +#### `test_get_rejects_invalid_pagination_params` + +بررسی می‌کند اگر `page=0` باشد: + +- پاسخ `400` بدهد +- پیام خطا مناسب برگردد + +#### `test_get_dispatches_zone_task_when_task_id_is_missing` + +با mock کردن `dispatch_zone_processing_tasks` بررسی می‌کند که: + +- اگر zone task_id نداشته باشد +- در زمان فراخوانی `AreaView` +- dispatch انجام شود + +#### `test_get_creates_area_when_sensor_has_no_data` + +با mock کردن `create_zones_and_dispatch` بررسی می‌کند که: + +- اگر sensor هنوز area نداشته باشد +- سیستم area جدید بسازد +- همان sensor را به service پاس بدهد + +#### `test_each_zone_gets_its_own_task` + +بررسی می‌کند برای دو zone جدا: + +- دو task مستقل ایجاد شود +- هر zone task_id جدا داشته باشد + +این تست خیلی مهم است چون تایید می‌کند taskها shared نیستند و per-zone هستند. + +#### `test_get_generates_local_task_id_when_broker_is_unavailable` + +با mock کردن celery و ایجاد `OperationalError` بررسی می‌کند که: + +- حتی وقتی broker در دسترس نیست +- سیستم task_id محلی بسازد +- response خراب نشود +- وضعیت کلی درست بماند + +#### `test_get_stores_task_id_and_reuses_it_on_next_request` + +بررسی می‌کند: + +- وقتی اولین request task_id را ثبت کرد +- request بعدی دوباره task تازه نسازد +- همان task_id قبلی reuse شود + +این تست جلوی dispatch تکراری را می‌گیرد. + +#### `test_get_redispatches_pending_zone_when_shared_task_already_completed` + +این تست سناریوی قدیمی یا خراب را پوشش می‌دهد. + +سناریو: + +- یک zone completed شده +- zone دیگر pending مانده +- هر دو task_id یکسان دارند + +در این حالت سیستم باید zone stale را دوباره dispatch کند. + +این تست نشان می‌دهد منطق stale detection واقعا کار می‌کند. + +--- + +## جمع‌بندی معماری + +اگر خیلی خلاصه بخواهیم نقش هر فایل را بگوییم: + +### `views.py` + +لایه HTTP است. + +- request را می‌گیرد +- service مناسب را صدا می‌زند +- response برمی‌گرداند + +### `services.py` + +لایه business logic است. + +- area را validate می‌کند +- polygon را به zone تبدیل می‌کند +- recommendation اولیه و نهایی می‌سازد +- taskها را مدیریت می‌کند +- payload response را می‌سازد + +### `tests.py` + +لایه اطمینان از رفتار سیستم است. + +- ساخت area +- ساخت zone +- status task +- dispatch task +- stale task +- pagination +- خطاهای ورودی + +را تست می‌کند. + +--- + +## نکات مهم برای فرانت + +- endpoint `area` الان pagination دارد و `zones` همیشه همه zoneها را برنمی‌گرداند. +- تعداد کل zoneها از `task.total_zones` یا `pagination.total_zones` قابل خواندن است. +- تعداد کل صفحه‌ها از `pagination.total_pages` قابل خواندن است. +- برای نمایش progress باید از `task.progress_percent` و `task.status` استفاده شود. +- `task.status` وضعیت کلی area است، نه وضعیت تک‌تک zoneها. +- وضعیت هر zone داخل `processing_status` قرار دارد. +- در صورت نیاز به جزئیات بیشتر برای یک zone باید `ZoneDetailsView` صدا زده شود. + +--- + +## نکات مهم برای بک‌اند + +- منطق grid سازی و پردازش zoneها تقریبا کامل داخل `services.py` متمرکز شده است. +- `AreaView` عمدا thin نگه داشته شده تا business logic وارد view نشود. +- rule-based data نقش fallback سریع برای UI را دارد. +- data واقعی‌تر بعدا با taskهای تحلیل خاک جایگزین یا تکمیل می‌شود. +- تست‌ها بیشتر روی پایداری flow پردازش و task dispatch تمرکز دارند. + diff --git a/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_API.md b/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_API.md new file mode 100644 index 0000000..ce2737e --- /dev/null +++ b/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_API.md @@ -0,0 +1,285 @@ +# Crop Zoning API Guide For Frontend + +این فایل برای تیم فرانت نوشته شده و رفتار endpointهای ماژول `crop-zoning` را به صورت کاربردی توضیح می‌دهد. + +## Base Path + +```text +/api/crop-zoning/ +``` + +## Authentication + +- همه endpointها با تنظیم فعلی پروژه نیاز به احراز هویت دارند. +- هدر پیشنهادی: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +## Flow پیشنهادی فرانت + +1. ابتدا `GET /area/` را با `farm_uuid` صدا بزنید. +2. اگر `task.status` برابر `PENDING` یا `PROCESSING` بود، polling انجام دهید. +3. وقتی `task.status` برابر `SUCCESS` شد: + - `area` را برای polygon اصلی زمین استفاده کنید. + - `zones` را برای grid map و کارت‌های overview استفاده کنید. +4. برای legend محصولات، `GET /products/` را بزنید. + +## وضعیت‌های Task + +- `IDLE`: هنوز area/taskی برای مزرعه وجود ندارد. +- `PENDING`: تسک ساخته شده ولی پردازش هنوز شروع نشده یا در صف است. +- `PROCESSING`: بخشی از زون‌ها در حال پردازش هستند یا برخی کامل شده‌اند. +- `SUCCESS`: همه زون‌ها کامل پردازش شده‌اند. +- `FAILURE`: یک یا چند زون با خطا مواجه شده‌اند. + +## Stageهای Task + +- `waiting_to_start` +- `queued` +- `processing_zones` +- `continuing_processing` +- `completed` +- `failed` + +فیلد `stage_label` متن فارسی آماده برای نمایش در UI است. + +--- + +## 1) Get Area + +```http +GET /api/crop-zoning/area/?farm_uuid=&page=1&page_size=10 +``` + +### Query Params + +- `farm_uuid`: اجباری، UUID مزرعه +- `page`: اختیاری، شماره صفحه زون‌ها. پیش‌فرض `1` +- `page_size`: اختیاری، تعداد زون در هر صفحه. پیش‌فرض `10` + +### کاربرد + +- گرفتن آخرین area مربوط به مزرعه +- ساخت area و zoneها در صورت نبود داده +- دریافت وضعیت task +- دریافت لیست `zones` به صورت صفحه‌بندی‌شده برای نمایش روی نقشه +- دریافت اطلاعات pagination برای ساخت pager یا infinite loading در فرانت + +### نمونه پاسخ موفق + +```json +{ + "status": "success", + "data": { + "task": { + "status": "SUCCESS", + "stage": "completed", + "stage_label": "پردازش همه زون‌ها کامل شده است", + "area_uuid": "c0eaa4d7-92bf-4542-a60d-6010b45e7c96", + "total_zones": 364, + "completed_zones": 364, + "processing_zones": 0, + "pending_zones": 0, + "failed_zones": 0, + "remaining_zones": 0, + "progress_percent": 100, + "summary": { + "done": 364, + "in_progress": 0, + "remaining": 0, + "failed": 0 + }, + "message": "از مجموع 364 زون، 364 زون پردازش شده، 0 زون در حال پردازش و 0 زون باقی مانده است.", + "failed_zone_errors": [], + "cell_side_km": 0.1 + }, + "area": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [[[51.418934, 35.706815], [51.423054, 35.691062], [51.384258, 35.689389], [51.418934, 35.706815]]] + }, + "properties": { + "center": { + "latitude": 35.69575533, + "longitude": 51.40874867 + }, + "area_sqm": 3109868.97, + "cell_side_km": 0.1, + "area_hectares": 310.9869 + } + }, + "zones": [ + { + "zoneId": "zone-0", + "zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940", + "geometry": { + "type": "Polygon", + "coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]] + }, + "center": { + "latitude": 35.68983816, + "longitude": 51.38481102 + }, + "area_sqm": 9999.91, + "area_hectares": 1, + "sequence": 0, + "processing_status": "completed", + "processing_error": "", + "crop": "wheat", + "matchPercent": 89, + "waterNeed": "4820-5820 m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار", + "waterNeedLayer": { + "level": "medium", + "value": "4820-5820 m³/ha", + "color": "#0ea5e9" + }, + "soilQualityLayer": { + "level": "high", + "score": 89, + "color": "#22c55e" + }, + "cultivationRiskLayer": { + "level": "low", + "color": "#22c55e" + } + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 37, + "total_zones": 364, + "returned_zones": 10, + "has_next": true, + "has_previous": false + } + } +} +``` + +### رفتار pagination + +- `zones` فقط شامل زون‌های همان صفحه‌ای است که در query param فرستاده شده +- `task.total_zones` تعداد کل زون‌های area را نشان می‌دهد، نه فقط زون‌های همان صفحه +- `pagination.total_pages` تعداد کل صفحه‌ها را برای فرانت مشخص می‌کند +- `pagination.returned_zones` تعداد آیتم‌های برگشتی در همان response را نشان می‌دهد +- اگر `page` بزرگ‌تر از `total_pages` باشد، response خطا نمی‌دهد و فقط `zones` خالی برمی‌گردد + +### مثال‌ها + +#### صفحه اول با 10 زون در هر صفحه + +```http +GET /api/crop-zoning/area/?farm_uuid=&page=1&page_size=10 +``` + +#### صفحه سوم با 25 زون در هر صفحه + +```http +GET /api/crop-zoning/area/?farm_uuid=&page=3&page_size=25 +``` + +### فیلدهای مهم `zones` + +- `zoneId`: شناسه نمایشی زون، مثل `zone-0` +- `zoneUuid`: UUID داخلی زون +- `geometry`: polygon زون +- `center`: مرکز زون +- `area_sqm`: مساحت به متر مربع +- `area_hectares`: مساحت به هکتار +- `sequence`: ترتیب زون +- `processing_status`: یکی از `pending`, `processing`, `completed`, `failed` +- `processing_error`: متن خطا در صورت failure +- `crop`: محصول پیشنهادی +- `matchPercent`: درصد تطابق +- `waterNeed`: نیاز آبی پیشنهادی +- `estimatedProfit`: سود تخمینی +- `waterNeedLayer`: داده layer نیاز آبی +- `soilQualityLayer`: داده layer کیفیت خاک +- `cultivationRiskLayer`: داده layer ریسک کشت + +### فیلدهای مهم `pagination` + +- `page`: شماره صفحه فعلی +- `page_size`: تعداد زون در هر صفحه +- `total_pages`: تعداد کل صفحه‌ها +- `total_zones`: تعداد کل زون‌های area +- `returned_zones`: تعداد زون‌های برگشتی در response فعلی +- `has_next`: آیا صفحه بعدی وجود دارد یا نه +- `has_previous`: آیا صفحه قبلی وجود دارد یا نه + +### خطاها + +#### وقتی `farm_uuid` ارسال نشود + +```json +{ + "status": "error", + "message": "farm_uuid is required." +} +``` + +#### وقتی مزرعه پیدا نشود + +```json +{ + "status": "error", + "message": "Farm not found." +} +``` + +#### وقتی `page` یا `page_size` نامعتبر باشد + +```json +{ + "status": "error", + "message": "page must be a positive integer." +} +``` + +- همین رفتار برای `page_size` هم وجود دارد و پیام خطا به صورت + `page_size must be a positive integer.` برمی‌گردد. + +--- + +## 2) Get Products + +```http +GET /api/crop-zoning/products/ +``` + +### کاربرد + +- گرفتن لیست محصولات برای legend و labelها + +### نمونه پاسخ + +```json +{ + "status": "success", + "data": { + "products": [ + { + "id": "wheat", + "label": "گندم", + "color": "#6bcb77" + }, + { + "id": "canola", + "label": "کلزا", + "color": "#ffd93d" + }, + { + "id": "saffron", + "label": "زعفران", + "color": "#9b59b6" + } + ] + } +} +``` diff --git a/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md b/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md new file mode 100644 index 0000000..016c121 --- /dev/null +++ b/Modules/Backend/crop_zoning/CROP_ZONING_FRONTEND_LAYER_AREA_CHANGES.md @@ -0,0 +1,282 @@ +# Crop Zoning Layer Area API Changes For Frontend + +این فایل برای تیم فرانت نوشته شده و فقط تغییرات جدیدی را توضیح می‌دهد که برای endpointهای لایه‌ای ماژول `crop-zoning` اضافه شده‌اند. + +## خلاصه تغییر + +سه API جدید اضافه شده‌اند که از نظر ساختار response دقیقا شبیه `GET /area/` هستند: + +- `GET /api/crop-zoning/water-need/` +- `GET /api/crop-zoning/soil-quality/` +- `GET /api/crop-zoning/cultivation-risk/` + +هر سه endpoint: + +- به `farm_uuid` نیاز دارند +- از `page` و `page_size` پشتیبانی می‌کنند +- در صورت نبود داده، همان روند ساخت area و zone و dispatch task را مثل `area/` انجام می‌دهند +- همان ساختار `task`, `area`, `zones`, `pagination` را برمی‌گردانند + +## هدف این تغییر + +قبلا فرانت برای داده‌های لایه‌ای بیشتر به endpointهای `zones/...` متکی بود که خروجی آن‌ها فقط لیست زون‌ها بود. +الان برای هر لایه یک endpoint جدید دارید که خروجی آن برای صفحه map دقیقا با `area/` هم‌فرمت است و استفاده در UI ساده‌تر می‌شود. + +## Base Path + +```text +/api/crop-zoning/ +``` + +## Authentication + +```http +Authorization: Bearer +Content-Type: application/json +``` + +## Endpointهای جدید + +### 1) Water Need + +```http +GET /api/crop-zoning/water-need/?farm_uuid=&page=1&page_size=10 +``` + +### 2) Soil Quality + +```http +GET /api/crop-zoning/soil-quality/?farm_uuid=&page=1&page_size=10 +``` + +### 3) Cultivation Risk + +```http +GET /api/crop-zoning/cultivation-risk/?farm_uuid=&page=1&page_size=10 +``` + +## Query Params + +- `farm_uuid`: اجباری، UUID مزرعه +- `page`: اختیاری، شماره صفحه زون‌ها، پیش‌فرض `1` +- `page_size`: اختیاری، تعداد زون در هر صفحه، پیش‌فرض `10` + +## ساختار کلی response + +ساختار کلی هر سه API: + +```json +{ + "status": "success", + "data": { + "task": {}, + "area": {}, + "zones": [], + "pagination": {} + } +} +``` + +یعنی برای فرانت: + +- `task` دقیقا مثل `area/` است +- `area` دقیقا مثل `area/` است +- `pagination` دقیقا مثل `area/` است +- فقط shape آیتم‌های داخل `zones` بر اساس نوع لایه فرق می‌کند + +## تفاوت `zones` در هر endpoint + +### `GET /water-need/` + +هر آیتم در `zones` این فیلدها را دارد: + +- `zoneId` +- `zoneUuid` +- `geometry` +- `center` +- `area_sqm` +- `area_hectares` +- `sequence` +- `processing_status` +- `processing_error` +- `waterNeedLayer` + +نمونه: + +```json +{ + "zoneId": "zone-0", + "zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940", + "geometry": { + "type": "Polygon", + "coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]] + }, + "center": { + "latitude": 35.68983816, + "longitude": 51.38481102 + }, + "area_sqm": 9999.91, + "area_hectares": 1, + "sequence": 0, + "processing_status": "completed", + "processing_error": "", + "waterNeedLayer": { + "level": "medium", + "value": "4820-5820 m³/ha", + "color": "#0ea5e9" + } +} +``` + +### `GET /soil-quality/` + +هر آیتم در `zones` این فیلدها را دارد: + +- `zoneId` +- `zoneUuid` +- `geometry` +- `center` +- `area_sqm` +- `area_hectares` +- `sequence` +- `processing_status` +- `processing_error` +- `soilQualityLayer` + +نمونه: + +```json +{ + "zoneId": "zone-0", + "zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940", + "geometry": { + "type": "Polygon", + "coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]] + }, + "center": { + "latitude": 35.68983816, + "longitude": 51.38481102 + }, + "area_sqm": 9999.91, + "area_hectares": 1, + "sequence": 0, + "processing_status": "completed", + "processing_error": "", + "soilQualityLayer": { + "level": "high", + "score": 89, + "color": "#22c55e" + } +} +``` + +### `GET /cultivation-risk/` + +هر آیتم در `zones` این فیلدها را دارد: + +- `zoneId` +- `zoneUuid` +- `geometry` +- `center` +- `area_sqm` +- `area_hectares` +- `sequence` +- `processing_status` +- `processing_error` +- `cultivationRiskLayer` + +نمونه: + +```json +{ + "zoneId": "zone-0", + "zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940", + "geometry": { + "type": "Polygon", + "coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]] + }, + "center": { + "latitude": 35.68983816, + "longitude": 51.38481102 + }, + "area_sqm": 9999.91, + "area_hectares": 1, + "sequence": 0, + "processing_status": "completed", + "processing_error": "", + "cultivationRiskLayer": { + "level": "low", + "color": "#22c55e" + } +} +``` + +## نکته مهم برای فرانت + +این endpointها عمدا شبیه `area/` طراحی شده‌اند تا فرانت بتواند با یک data flow یکسان کار کند: + +- polygon اصلی را از `data.area` بگیرد +- task status را از `data.task` بخواند +- pagination را از `data.pagination` بخواند +- فقط renderer مربوط به هر لایه را روی `data.zones` اعمال کند + +## پیشنهاد استفاده در UI + +### اگر صفحه overview اصلی دارید + +- همچنان `GET /area/` بهترین گزینه برای صفحه overview کامل است، چون علاوه بر layerها فیلدهای crop و recommendation را هم داخل هر zone دارد. + +### اگر صفحه یا tab مخصوص هر layer دارید + +- برای تب نیاز آبی: `GET /water-need/` +- برای تب کیفیت خاک: `GET /soil-quality/` +- برای تب ریسک کشت: `GET /cultivation-risk/` + +این کار باعث می‌شود فرانت فقط داده موردنیاز همان layer را بگیرد. + +## وضعیت backward compatibility + +- endpoint قدیمی `GET /area/` بدون تغییر باقی مانده است +- endpointهای جدید breaking change ایجاد نمی‌کنند +- فقط سه مسیر جدید به API اضافه شده است + +## خطاها + +رفتار خطاها مثل `area/` است. + +### نبودن `farm_uuid` + +```json +{ + "status": "error", + "message": "farm_uuid is required." +} +``` + +### پیدا نشدن مزرعه + +```json +{ + "status": "error", + "message": "Farm not found." +} +``` + +### نامعتبر بودن `page` یا `page_size` + +```json +{ + "status": "error", + "message": "page must be a positive integer." +} +``` + +## جمع‌بندی + +تغییر جدید برای فرانت این است که الان به جز `area/`، سه API جدید هم دارید که: + +- از نظر query params شبیه `area/` هستند +- از نظر response wrapper شبیه `area/` هستند +- فقط payload داخلی `zones` را بر اساس نوع layer تخصصی می‌کنند + +در نتیجه اگر UI شما برای `area/` آماده است، اتصال این سه endpoint جدید باید با کمترین تغییر انجام شود. diff --git a/Modules/Backend/crop_zoning/__init__.py b/Modules/Backend/crop_zoning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/crop_zoning/apps.py b/Modules/Backend/crop_zoning/apps.py new file mode 100644 index 0000000..aeffd26 --- /dev/null +++ b/Modules/Backend/crop_zoning/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class CropZoningConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "crop_zoning" + verbose_name = "Crop Zoning" + + def ready(self): + from . import tasks # noqa: F401 diff --git a/Modules/Backend/crop_zoning/defaults.py b/Modules/Backend/crop_zoning/defaults.py new file mode 100644 index 0000000..7b5ee67 --- /dev/null +++ b/Modules/Backend/crop_zoning/defaults.py @@ -0,0 +1,27 @@ +DEFAULT_AREA_FEATURE = { + "area": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.405, 35.672], + [51.41, 35.695], + [51.385, 35.71], + [51.365, 35.688], + [51.38, 35.68], + ] + ], + }, + } +} + +DEFAULT_PRODUCTS_PAYLOAD = { + "products": [ + {"id": "wheat", "label": "گندم", "color": "#6bcb77"}, + {"id": "canola", "label": "کلزا", "color": "#ffd93d"}, + {"id": "saffron", "label": "زعفران", "color": "#9b59b6"}, + ] +} diff --git a/Modules/Backend/crop_zoning/migrations/0001_initial.py b/Modules/Backend/crop_zoning/migrations/0001_initial.py new file mode 100644 index 0000000..01019d6 --- /dev/null +++ b/Modules/Backend/crop_zoning/migrations/0001_initial.py @@ -0,0 +1,54 @@ +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='CropArea', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('geometry', models.JSONField(default=dict)), + ('points', models.JSONField(default=list)), + ('center', models.JSONField(default=dict)), + ('area_sqm', models.FloatField()), + ('area_hectares', models.FloatField()), + ('chunk_area_sqm', models.FloatField()), + ('zone_count', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'crop_areas', + 'ordering': ['-created_at', '-id'], + }, + ), + migrations.CreateModel( + name='CropZone', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ('zone_id', models.CharField(max_length=64)), + ('geometry', models.JSONField(default=dict)), + ('points', models.JSONField(default=list)), + ('center', models.JSONField(default=dict)), + ('area_sqm', models.FloatField()), + ('area_hectares', models.FloatField()), + ('sequence', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('crop_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='zones', to='crop_zoning.croparea')), + ], + options={ + 'db_table': 'crop_zones', + 'ordering': ['sequence', 'id'], + 'constraints': [models.UniqueConstraint(fields=('crop_area', 'zone_id'), name='unique_crop_area_zone_id')], + }, + ), + ] diff --git a/Modules/Backend/crop_zoning/migrations/0002_crop_zoning_mock_schema.py b/Modules/Backend/crop_zoning/migrations/0002_crop_zoning_mock_schema.py new file mode 100644 index 0000000..056c678 --- /dev/null +++ b/Modules/Backend/crop_zoning/migrations/0002_crop_zoning_mock_schema.py @@ -0,0 +1,99 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("crop_zoning", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="CropProduct", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("product_id", models.CharField(max_length=64, unique=True)), + ("label", models.CharField(max_length=255)), + ("color", models.CharField(max_length=32)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "crop_products", + "ordering": ["id"], + }, + ), + migrations.CreateModel( + name="CropZoneCultivationRiskLayer", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)), + ("color", models.CharField(max_length=32)), + ("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="cultivation_risk_layer", to="crop_zoning.cropzone")), + ], + options={ + "db_table": "crop_zone_cultivation_risk_layers", + "ordering": ["crop_zone_id"], + }, + ), + migrations.CreateModel( + name="CropZoneRecommendation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("match_percent", models.PositiveIntegerField()), + ("water_need", models.CharField(max_length=128)), + ("estimated_profit", models.CharField(max_length=128)), + ("reason", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="recommendation", to="crop_zoning.cropzone")), + ("product", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="zone_recommendations", to="crop_zoning.cropproduct")), + ], + options={ + "db_table": "crop_zone_recommendations", + "ordering": ["crop_zone_id"], + }, + ), + migrations.CreateModel( + name="CropZoneSoilQualityLayer", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)), + ("score", models.PositiveIntegerField()), + ("color", models.CharField(max_length=32)), + ("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="soil_quality_layer", to="crop_zoning.cropzone")), + ], + options={ + "db_table": "crop_zone_soil_quality_layers", + "ordering": ["crop_zone_id"], + }, + ), + migrations.CreateModel( + name="CropZoneWaterNeedLayer", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)), + ("value", models.CharField(max_length=128)), + ("color", models.CharField(max_length=32)), + ("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="water_need_layer", to="crop_zoning.cropzone")), + ], + options={ + "db_table": "crop_zone_water_need_layers", + "ordering": ["crop_zone_id"], + }, + ), + migrations.CreateModel( + name="CropZoneCriteria", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=128)), + ("value", models.PositiveIntegerField()), + ("sequence", models.PositiveIntegerField(default=0)), + ("recommendation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="criteria", to="crop_zoning.cropzonerecommendation")), + ], + options={ + "db_table": "crop_zone_criteria", + "ordering": ["sequence", "id"], + }, + ), + ] diff --git a/Modules/Backend/crop_zoning/migrations/0003_zone_processing_and_analysis.py b/Modules/Backend/crop_zoning/migrations/0003_zone_processing_and_analysis.py new file mode 100644 index 0000000..dc36a81 --- /dev/null +++ b/Modules/Backend/crop_zoning/migrations/0003_zone_processing_and_analysis.py @@ -0,0 +1,49 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("crop_zoning", "0002_crop_zoning_mock_schema"), + ] + + operations = [ + migrations.AddField( + model_name="cropzone", + name="processing_error", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="cropzone", + name="processing_status", + field=models.CharField( + choices=[("pending", "Pending"), ("processing", "Processing"), ("completed", "Completed"), ("failed", "Failed")], + default="pending", + max_length=16, + ), + ), + migrations.AddField( + model_name="cropzone", + name="task_id", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.CreateModel( + name="CropZoneAnalysis", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("source", models.CharField(blank=True, default="", max_length=64)), + ("external_record_id", models.CharField(blank=True, default="", max_length=64)), + ("latitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)), + ("longitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)), + ("raw_response", models.JSONField(blank=True, default=dict)), + ("depths", models.JSONField(blank=True, default=list)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="analysis", to="crop_zoning.cropzone")), + ], + options={ + "db_table": "crop_zone_analyses", + "ordering": ["crop_zone_id"], + }, + ), + ] diff --git a/Modules/Backend/crop_zoning/migrations/0004_croparea_farm.py b/Modules/Backend/crop_zoning/migrations/0004_croparea_farm.py new file mode 100644 index 0000000..613d981 --- /dev/null +++ b/Modules/Backend/crop_zoning/migrations/0004_croparea_farm.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ("crop_zoning", "0003_zone_processing_and_analysis"), + ] + + operations = [ + migrations.AddField( + model_name="croparea", + name="farm", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="crop_areas", + to="farm_hub.farmhub", + ), + ), + ] diff --git a/Modules/Backend/crop_zoning/migrations/__init__.py b/Modules/Backend/crop_zoning/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/crop_zoning/mock_data.py b/Modules/Backend/crop_zoning/mock_data.py new file mode 100644 index 0000000..5289fbd --- /dev/null +++ b/Modules/Backend/crop_zoning/mock_data.py @@ -0,0 +1,354 @@ +""" +Static mock data for Crop Zoning API. +Matches CROP_ZONING_APIS.md. No database, no dynamic values. +""" + +# --------------------------------------------------------------------------- +# GET /api/crop-zoning/area/ +# منطقهٔ ثابت — کاربر امکان رسم ندارد +# --------------------------------------------------------------------------- + +AREA_RESPONSE_DATA = { + "area": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.405, 35.672], + [51.41, 35.695], + [51.385, 35.71], + [51.365, 35.688], + [51.38, 35.68], + ] + ], + }, + } +} + +# --------------------------------------------------------------------------- +# GET /api/crop-zoning/products/ +# --------------------------------------------------------------------------- + +PRODUCTS_RESPONSE_DATA = { + "products": [ + {"id": "wheat", "label": "گندم", "color": "#6bcb77"}, + {"id": "canola", "label": "کلزا", "color": "#ffd93d"}, + {"id": "saffron", "label": "زعفران", "color": "#9b59b6"}, + ] +} + +# --------------------------------------------------------------------------- +# POST /api/crop-zoning/zones/initial/ +# دیتای اولیه برای نقشه و هاور/tooltip — بدون reason و criteria +# --------------------------------------------------------------------------- + +ZONES_INITIAL_RESPONSE_DATA = { + "total_area_hectares": 23.45, + "total_area_sqm": 234500, + "zone_count": 3, + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68], + ] + ], + }, + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار", + }, + { + "zoneId": "zone-1", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.3815, 35.68], + [51.383, 35.68], + [51.383, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.68], + ] + ], + }, + "crop": "canola", + "matchPercent": 78, + "waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha", + "estimatedProfit": "۲۰-۳۵ میلیون/هکتار", + }, + { + "zoneId": "zone-2", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.683], + [51.38, 35.683], + [51.38, 35.6815], + ] + ], + }, + "crop": "saffron", + "matchPercent": 92, + "waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha", + "estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار", + }, + ], +} + +# --------------------------------------------------------------------------- +# POST /api/crop-zoning/zones/water-need/ +# نیاز آبی هر منطقه برای لایهٔ نیاز آبی +# --------------------------------------------------------------------------- + +ZONES_WATER_NEED_RESPONSE_DATA = { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68], + ] + ], + }, + "level": "medium", + "value": "۴۵۰۰-۵۵۰۰ m³/ha", + "color": "#0ea5e9", + }, + { + "zoneId": "zone-1", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.3815, 35.68], + [51.383, 35.68], + [51.383, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.68], + ] + ], + }, + "level": "high", + "value": "۵۰۰۰-۶۰۰۰ m³/ha", + "color": "#0369a1", + }, + { + "zoneId": "zone-2", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.683], + [51.38, 35.683], + [51.38, 35.6815], + ] + ], + }, + "level": "low", + "value": "۳۰۰۰-۴۰۰۰ m³/ha", + "color": "#7dd3fc", + }, + ] +} + +# --------------------------------------------------------------------------- +# POST /api/crop-zoning/zones/soil-quality/ +# کیفیت خاک هر منطقه برای لایهٔ کیفیت خاک +# --------------------------------------------------------------------------- + +ZONES_SOIL_QUALITY_RESPONSE_DATA = { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68], + ] + ], + }, + "level": "high", + "score": 88, + "color": "#22c55e", + }, + { + "zoneId": "zone-1", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.3815, 35.68], + [51.383, 35.68], + [51.383, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.68], + ] + ], + }, + "level": "medium", + "score": 62, + "color": "#eab308", + }, + { + "zoneId": "zone-2", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.683], + [51.38, 35.683], + [51.38, 35.6815], + ] + ], + }, + "level": "high", + "score": 95, + "color": "#22c55e", + }, + ] +} + +# --------------------------------------------------------------------------- +# POST /api/crop-zoning/zones/cultivation-risk/ +# ریسک کشت هر منطقه برای لایهٔ ریسک کشت +# --------------------------------------------------------------------------- + +ZONES_CULTIVATION_RISK_RESPONSE_DATA = { + "zones": [ + { + "zoneId": "zone-0", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.68], + [51.3815, 35.68], + [51.3815, 35.6815], + [51.38, 35.6815], + [51.38, 35.68], + ] + ], + }, + "level": "low", + "color": "#22c55e", + }, + { + "zoneId": "zone-1", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.3815, 35.68], + [51.383, 35.68], + [51.383, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.68], + ] + ], + }, + "level": "medium", + "color": "#f59e0b", + }, + { + "zoneId": "zone-2", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.38, 35.6815], + [51.3815, 35.6815], + [51.3815, 35.683], + [51.38, 35.683], + [51.38, 35.6815], + ] + ], + }, + "level": "low", + "color": "#22c55e", + }, + ] +} + +# --------------------------------------------------------------------------- +# GET /api/crop-zoning/zones/:zoneId/details/ +# دیتای تکمیلی برای پنل جزئیات — شامل reason و criteria +# منطبق با createZonedGrid و MOCK_AREA_GEOJSON +# --------------------------------------------------------------------------- + +ZONE_DETAILS_BY_ID = { + "zone-0": { + "zoneId": "zone-0", + "crop": "wheat", + "matchPercent": 85, + "waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha", + "estimatedProfit": "۱۵-۲۵ میلیون/هکتار", + "reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی", + "criteria": [ + {"name": "دما", "value": 82}, + {"name": "بارش", "value": 75}, + {"name": "خاک", "value": 88}, + {"name": "آب", "value": 70}, + ], + "area_hectares": 2.25, + }, + "zone-1": { + "zoneId": "zone-1", + "crop": "canola", + "matchPercent": 78, + "waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha", + "estimatedProfit": "۲۰-۳۵ میلیون/هکتار", + "reason": "شرایط اقلیمی مساعد، نیاز آبی قابل تأمین", + "criteria": [ + {"name": "دما", "value": 75}, + {"name": "بارش", "value": 72}, + {"name": "خاک", "value": 80}, + {"name": "آب", "value": 78}, + ], + "area_hectares": 2.25, + }, + "zone-2": { + "zoneId": "zone-2", + "crop": "saffron", + "matchPercent": 92, + "waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha", + "estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار", + "reason": "ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا", + "criteria": [ + {"name": "دما", "value": 90}, + {"name": "بارش", "value": 65}, + {"name": "خاک", "value": 95}, + {"name": "آب", "value": 85}, + ], + "area_hectares": 2.25, + }, +} diff --git a/Modules/Backend/crop_zoning/models.py b/Modules/Backend/crop_zoning/models.py new file mode 100644 index 0000000..3e5fd7e --- /dev/null +++ b/Modules/Backend/crop_zoning/models.py @@ -0,0 +1,224 @@ +import uuid + +from django.db import models +from farm_hub.models import FarmHub + + +class CropArea(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="crop_areas", + null=True, + blank=True, + db_index=True, + ) + geometry = models.JSONField(default=dict) + points = models.JSONField(default=list) + center = models.JSONField(default=dict) + area_sqm = models.FloatField() + area_hectares = models.FloatField() + chunk_area_sqm = models.FloatField() + zone_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "crop_areas" + ordering = ["-created_at", "-id"] + + def __str__(self): + return f"Area {self.uuid}" + + +class CropZone(models.Model): + STATUS_PENDING = "pending" + STATUS_PROCESSING = "processing" + STATUS_COMPLETED = "completed" + STATUS_FAILED = "failed" + STATUS_CHOICES = ( + (STATUS_PENDING, "Pending"), + (STATUS_PROCESSING, "Processing"), + (STATUS_COMPLETED, "Completed"), + (STATUS_FAILED, "Failed"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + crop_area = models.ForeignKey( + CropArea, + on_delete=models.CASCADE, + related_name="zones", + ) + zone_id = models.CharField(max_length=64) + geometry = models.JSONField(default=dict) + points = models.JSONField(default=list) + center = models.JSONField(default=dict) + area_sqm = models.FloatField() + area_hectares = models.FloatField() + sequence = models.PositiveIntegerField() + processing_status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING) + processing_error = models.TextField(blank=True, default="") + task_id = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "crop_zones" + ordering = ["sequence", "id"] + constraints = [ + models.UniqueConstraint(fields=["crop_area", "zone_id"], name="unique_crop_area_zone_id"), + ] + + def __str__(self): + return self.zone_id + + +class CropProduct(models.Model): + product_id = models.CharField(max_length=64, unique=True) + label = models.CharField(max_length=255) + color = models.CharField(max_length=32) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "crop_products" + ordering = ["id"] + + def __str__(self): + return self.label + + +class CropZoneRecommendation(models.Model): + crop_zone = models.OneToOneField( + CropZone, + on_delete=models.CASCADE, + related_name="recommendation", + ) + product = models.ForeignKey( + CropProduct, + on_delete=models.PROTECT, + related_name="zone_recommendations", + ) + match_percent = models.PositiveIntegerField() + water_need = models.CharField(max_length=128) + estimated_profit = models.CharField(max_length=128) + reason = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "crop_zone_recommendations" + ordering = ["crop_zone_id"] + + def __str__(self): + return f"{self.crop_zone.zone_id} -> {self.product.product_id}" + + +class CropZoneCriteria(models.Model): + recommendation = models.ForeignKey( + CropZoneRecommendation, + on_delete=models.CASCADE, + related_name="criteria", + ) + name = models.CharField(max_length=128) + value = models.PositiveIntegerField() + sequence = models.PositiveIntegerField(default=0) + + class Meta: + db_table = "crop_zone_criteria" + ordering = ["sequence", "id"] + + def __str__(self): + return f"{self.name}: {self.value}" + + +class CropZoneWaterNeedLayer(models.Model): + LEVEL_LOW = "low" + LEVEL_MEDIUM = "medium" + LEVEL_HIGH = "high" + LEVEL_CHOICES = ( + (LEVEL_LOW, "Low"), + (LEVEL_MEDIUM, "Medium"), + (LEVEL_HIGH, "High"), + ) + + crop_zone = models.OneToOneField( + CropZone, + on_delete=models.CASCADE, + related_name="water_need_layer", + ) + level = models.CharField(max_length=16, choices=LEVEL_CHOICES) + value = models.CharField(max_length=128) + color = models.CharField(max_length=32) + + class Meta: + db_table = "crop_zone_water_need_layers" + ordering = ["crop_zone_id"] + + +class CropZoneSoilQualityLayer(models.Model): + LEVEL_LOW = "low" + LEVEL_MEDIUM = "medium" + LEVEL_HIGH = "high" + LEVEL_CHOICES = ( + (LEVEL_LOW, "Low"), + (LEVEL_MEDIUM, "Medium"), + (LEVEL_HIGH, "High"), + ) + + crop_zone = models.OneToOneField( + CropZone, + on_delete=models.CASCADE, + related_name="soil_quality_layer", + ) + level = models.CharField(max_length=16, choices=LEVEL_CHOICES) + score = models.PositiveIntegerField() + color = models.CharField(max_length=32) + + class Meta: + db_table = "crop_zone_soil_quality_layers" + ordering = ["crop_zone_id"] + + +class CropZoneCultivationRiskLayer(models.Model): + LEVEL_LOW = "low" + LEVEL_MEDIUM = "medium" + LEVEL_HIGH = "high" + LEVEL_CHOICES = ( + (LEVEL_LOW, "Low"), + (LEVEL_MEDIUM, "Medium"), + (LEVEL_HIGH, "High"), + ) + + crop_zone = models.OneToOneField( + CropZone, + on_delete=models.CASCADE, + related_name="cultivation_risk_layer", + ) + level = models.CharField(max_length=16, choices=LEVEL_CHOICES) + color = models.CharField(max_length=32) + + class Meta: + db_table = "crop_zone_cultivation_risk_layers" + ordering = ["crop_zone_id"] + + +class CropZoneAnalysis(models.Model): + source = models.CharField(max_length=64, blank=True, default="") + external_record_id = models.CharField(max_length=64, blank=True, default="") + latitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True) + longitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True) + raw_response = models.JSONField(default=dict, blank=True) + depths = models.JSONField(default=list, blank=True) + crop_zone = models.OneToOneField( + CropZone, + on_delete=models.CASCADE, + related_name="analysis", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "crop_zone_analyses" + ordering = ["crop_zone_id"] diff --git a/Modules/Backend/crop_zoning/postman/crop_zoning.json b/Modules/Backend/crop_zoning/postman/crop_zoning.json new file mode 100644 index 0000000..0f8b118 --- /dev/null +++ b/Modules/Backend/crop_zoning/postman/crop_zoning.json @@ -0,0 +1 @@ +{"info":{"name":"Crop Zoning","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Crop Zoning API. GET area. GET products. POST zones/initial (crops). POST zones/water-need, soil-quality, cultivation-risk (layer data). GET zones/:zoneId/details (detail panel)."},"item":[{"name":"Get area (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/area/?farm_uuid={{farmUuid}}","description":"Returns task status and area for the requested farm. If the farm has no crop-zoning data yet, it creates data and dispatches a Celery task. Only one active task is allowed per farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"area\": {\n \"type\": \"Feature\",\n \"properties\": {},\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.405, 35.672], [51.41, 35.695], [51.385, 35.71], [51.365, 35.688], [51.38, 35.68]]]\n }\n }\n }\n}"}]},{"name":"Get products (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/products/","description":"Returns static list of cultivable products (id, label, color) for Legend and zone detail panel."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"products\": [\n {\"id\": \"wheat\", \"label\": \"گندم\", \"color\": \"#6bcb77\"},\n {\"id\": \"canola\", \"label\": \"کلزا\", \"color\": \"#ffd93d\"},\n {\"id\": \"saffron\", \"label\": \"زعفران\", \"color\": \"#9b59b6\"}\n ]\n }\n}"}]},{"name":"Zones initial (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]\n },\n \"properties\": {\"index\": 0}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]\n },\n \"properties\": {\"index\": 1}\n },\n {\n \"type\": \"Feature\",\n \"geometry\": {\n \"type\": \"Polygon\",\n \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]\n },\n \"properties\": {\"index\": 2}\n }\n ]\n },\n \"products\": [\"wheat\", \"canola\", \"saffron\"]\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/initial/","description":"Body: zones (FeatureCollection of grid squares), optional products. Returns initial data for map and hover/tooltip (no reason, criteria)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"total_area_hectares\": 23.45,\n \"total_area_sqm\": 234500,\n \"zone_count\": 3,\n \"zones\": [\n {\n \"zoneId\": \"zone-0\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]},\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-1\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]},\n \"crop\": \"canola\",\n \"matchPercent\": 78,\n \"waterNeed\": \"۵۰۰۰-۶۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۲۰-۳۵ میلیون/هکتار\"\n },\n {\n \"zoneId\": \"zone-2\",\n \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]},\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\"\n }\n ]\n }\n}"}]},{"name":"Zones water-need (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/water-need/","description":"Returns water need per zone for water need layer (level, value, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"medium\", \"value\": \"۴۵۰۰-۵۵۰۰ m³/ha\", \"color\": \"#0ea5e9\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"high\", \"value\": \"۵۰۰۰-۶۰۰۰ m³/ha\", \"color\": \"#0369a1\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"value\": \"۳۰۰۰-۴۰۰۰ m³/ha\", \"color\": \"#7dd3fc\"}\n ]\n }\n}"}]},{"name":"Zones soil-quality (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/soil-quality/","description":"Returns soil quality per zone for soil quality layer (level, score, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"high\", \"score\": 88, \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"score\": 62, \"color\": \"#eab308\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"high\", \"score\": 95, \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zones cultivation-risk (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"zones\": {\n \"type\": \"FeatureCollection\",\n \"features\": [\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"properties\": {\"index\": 0}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"properties\": {\"index\": 1}},\n {\"type\": \"Feature\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"properties\": {\"index\": 2}}\n ]\n }\n}"},"url":"{{baseUrl}}/api/crop-zoning/zones/cultivation-risk/","description":"Returns cultivation risk per zone for risk layer (level, color)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zones\": [\n {\"zoneId\": \"zone-0\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.68], [51.3815, 35.68], [51.3815, 35.6815], [51.38, 35.6815], [51.38, 35.68]]]}, \"level\": \"low\", \"color\": \"#22c55e\"},\n {\"zoneId\": \"zone-1\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.3815, 35.68], [51.383, 35.68], [51.383, 35.6815], [51.3815, 35.6815], [51.3815, 35.68]]]}, \"level\": \"medium\", \"color\": \"#f59e0b\"},\n {\"zoneId\": \"zone-2\", \"geometry\": {\"type\": \"Polygon\", \"coordinates\": [[[51.38, 35.6815], [51.3815, 35.6815], [51.3815, 35.683], [51.38, 35.683], [51.38, 35.6815]]]}, \"level\": \"low\", \"color\": \"#22c55e\"}\n ]\n }\n}"}]},{"name":"Zone details (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-0/details/","description":"Returns detail data for one zone (reason, criteria, area_hectares) for detail panel and radar chart."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-0\",\n \"crop\": \"wheat\",\n \"matchPercent\": 85,\n \"waterNeed\": \"۴۵۰۰-۵۵۰۰ m³/ha\",\n \"estimatedProfit\": \"۱۵-۲۵ میلیون/هکتار\",\n \"reason\": \"دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 82}, {\"name\": \"بارش\", \"value\": 75}, {\"name\": \"خاک\", \"value\": 88}, {\"name\": \"آب\", \"value\": 70}],\n \"area_hectares\": 2.25\n }\n}"}]},{"name":"Zone details zone-2 (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/crop-zoning/zones/zone-2/details/","description":"Returns detail data for zone-2 (saffron)."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"zoneId\": \"zone-2\",\n \"crop\": \"saffron\",\n \"matchPercent\": 92,\n \"waterNeed\": \"۳۰۰۰-۴۰۰۰ m³/ha\",\n \"estimatedProfit\": \"۵۰-۱۵۰ میلیون/هکتار\",\n \"reason\": \"ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا\",\n \"criteria\": [{\"name\": \"دما\", \"value\": 90}, {\"name\": \"بارش\", \"value\": 65}, {\"name\": \"خاک\", \"value\": 95}, {\"name\": \"آب\", \"value\": 85}],\n \"area_hectares\": 2.25\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"farmUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} diff --git a/Modules/Backend/crop_zoning/services.py b/Modules/Backend/crop_zoning/services.py new file mode 100644 index 0000000..161b138 --- /dev/null +++ b/Modules/Backend/crop_zoning/services.py @@ -0,0 +1,1213 @@ +import math +from copy import deepcopy +from decimal import Decimal +from datetime import timedelta + +from django.conf import settings +from celery.result import AsyncResult +from kombu.exceptions import OperationalError +from django.db import transaction +from django.db.models import Prefetch +from django.utils import timezone +from farm_hub.models import FarmHub + +from external_api_adapter.adapter import request as external_request + +from .defaults import DEFAULT_AREA_FEATURE, DEFAULT_PRODUCTS_PAYLOAD +from .models import ( + CropArea, + CropProduct, + CropZone, + CropZoneAnalysis, + CropZoneCriteria, + CropZoneCultivationRiskLayer, + CropZoneRecommendation, + CropZoneSoilQualityLayer, + CropZoneWaterNeedLayer, +) + +EARTH_RADIUS_METERS = 6378137.0 +PRODUCT_DEFAULTS = DEFAULT_PRODUCTS_PAYLOAD["products"] +DEFAULT_CELL_SIDE_KM = 0.15 +DEFAULT_ZONE_PAGE_SIZE = 10 +RULE_BASED_ALGORITHM = "rule_based_v1" +RULE_BASED_PRODUCTS = { + "wheat": { + "water_need": "۴۵۰۰-۵۵۰۰ m³/ha", + "water_need_level": "medium", + "estimated_profit": "۱۵-۲۵ میلیون/هکتار", + "reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی", + }, + "canola": { + "water_need": "۵۰۰۰-۶۰۰۰ m³/ha", + "water_need_level": "high", + "estimated_profit": "۲۰-۳۵ میلیون/هکتار", + "reason": "پایداری بهتر در برابر نوسان دما و پتانسیل سود اقتصادی مناسب", + }, + "saffron": { + "water_need": "۳۰۰۰-۴۰۰۰ m³/ha", + "water_need_level": "low", + "estimated_profit": "۵۰-۱۵۰ میلیون/هکتار", + "reason": "اقلیم خشک‌تر و نیاز آبی کمتر این زون برای زعفران مناسب‌تر است", + }, +} +RULE_BASED_CROP_IDS = tuple(RULE_BASED_PRODUCTS.keys()) +TASK_STATE_PENDING = "PENDING" +TASK_STATE_STARTED = "STARTED" +TASK_STATE_RETRY = "RETRY" +TASK_STATE_SUCCESS = "SUCCESS" +TASK_STATE_FAILURE = "FAILURE" +TASK_STATE_REVOKED = "REVOKED" + + +def get_default_cell_side_km(): + raw_value = getattr(settings, "CROP_ZONE_CELL_SIDE_KM", None) + try: + cell_side_km = float(raw_value) + except (TypeError, ValueError): + cell_side_km = 0 + if cell_side_km > 0: + return cell_side_km + + raw_value = getattr(settings, "CROP_ZONE_CHUNK_AREA_SQM", 0) + try: + chunk_area = float(raw_value) + except (TypeError, ValueError): + chunk_area = 0 + if chunk_area > 0: + return math.sqrt(chunk_area) / 1000.0 + + return DEFAULT_CELL_SIDE_KM + + +def get_task_stale_seconds(): + raw_value = getattr(settings, "CROP_ZONE_TASK_STALE_SECONDS", 300) + try: + stale_seconds = int(raw_value) + except (TypeError, ValueError): + stale_seconds = 300 + return max(stale_seconds, 0) + + +def get_cell_side_km(cell_side_km=None): + if cell_side_km is None or cell_side_km == "": + resolved_value = get_default_cell_side_km() + else: + try: + resolved_value = float(cell_side_km) + except (TypeError, ValueError) as exc: + raise ValueError("cell_side_km must be a positive number.") from exc + + if resolved_value <= 0: + raise ValueError("cell_side_km must be a positive number.") + return resolved_value + + +def get_chunk_area_sqm(cell_side_km=None): + resolved_cell_side_km = get_cell_side_km(cell_side_km) + return (resolved_cell_side_km * 1000.0) ** 2 + + +def parse_positive_int(value, field_name, default=None): + if value in {None, ""}: + if default is None: + raise ValueError(f"{field_name} must be a positive integer.") + return default + + try: + parsed_value = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field_name} must be a positive integer.") from exc + + if parsed_value <= 0: + raise ValueError(f"{field_name} must be a positive integer.") + return parsed_value + + +def get_zone_page_request_params(query_params): + return ( + parse_positive_int(query_params.get("page"), "page", default=1), + parse_positive_int(query_params.get("page_size"), "page_size", default=DEFAULT_ZONE_PAGE_SIZE), + ) + + +def get_default_area_feature(): + return deepcopy(DEFAULT_AREA_FEATURE["area"]) + + +def normalize_area_feature(area_feature): + if area_feature is None: + raise ValueError("Area polygon coordinates are required.") + if not isinstance(area_feature, dict): + raise ValueError("Area GeoJSON must be an object.") + + if area_feature.get("type") == "Feature": + geometry = deepcopy(area_feature.get("geometry") or {}) + normalized_feature = { + "type": "Feature", + "properties": deepcopy(area_feature.get("properties") or {}), + "geometry": geometry, + } + else: + normalized_feature = { + "type": "Feature", + "properties": {}, + "geometry": deepcopy(area_feature), + } + + geometry = normalized_feature.get("geometry") or {} + if geometry.get("type") != "Polygon": + raise ValueError("Area GeoJSON geometry type must be Polygon.") + + ring = get_polygon_ring(normalized_feature) + if len(ring) < 4: + raise ValueError("Area polygon must contain at least four coordinates.") + + return normalized_feature + + +def ensure_products_exist(): + for product in PRODUCT_DEFAULTS: + CropProduct.objects.update_or_create( + product_id=product["id"], + defaults={"label": product["label"], "color": product["color"]}, + ) + + +def get_products_payload(): + ensure_products_exist() + products = CropProduct.objects.order_by("id") + return { + "products": [ + {"id": product.product_id, "label": product.label, "color": product.color} + for product in products + ] + } + + +def get_polygon_ring(area_feature): + geometry = (area_feature or {}).get("geometry", {}) + coordinates = geometry.get("coordinates", []) + if not coordinates or not coordinates[0]: + raise ValueError("Area polygon coordinates are required.") + return coordinates[0] + + +def polygon_area_sqm(ring): + if len(ring) < 4: + return 0.0 + + latitudes = [point[1] for point in ring] + mean_latitude = math.radians(sum(latitudes) / len(latitudes)) + + projected_points = [] + for longitude, latitude in ring: + x = math.radians(longitude) * EARTH_RADIUS_METERS * math.cos(mean_latitude) + y = math.radians(latitude) * EARTH_RADIUS_METERS + projected_points.append((x, y)) + + area = 0.0 + for index in range(len(projected_points) - 1): + x1, y1 = projected_points[index] + x2, y2 = projected_points[index + 1] + area += (x1 * y2) - (x2 * y1) + + return abs(area) / 2.0 + + +def normalize_points(ring): + if len(ring) > 1 and ring[0] == ring[-1]: + ring = ring[:-1] + return [[point[0], point[1]] for point in ring] + + +def calculate_center(points): + if not points: + return {"longitude": 0.0, "latitude": 0.0} + + longitude = sum(point[0] for point in points) / len(points) + latitude = sum(point[1] for point in points) / len(points) + return { + "longitude": round(longitude, 8), + "latitude": round(latitude, 8), + } + + +def get_bbox(points): + longitudes = [point[0] for point in points] + latitudes = [point[1] for point in points] + return { + "min_lng": min(longitudes), + "max_lng": max(longitudes), + "min_lat": min(latitudes), + "max_lat": max(latitudes), + } + + +def meters_to_latitude_delta(meters): + return meters / 111320.0 + + +def meters_to_longitude_delta(meters, latitude): + longitude_factor = 111320.0 * math.cos(math.radians(latitude)) + if abs(longitude_factor) < 1e-9: + longitude_factor = 1.0 + return meters / longitude_factor + + +def point_in_polygon(point, polygon_points): + x, y = point + inside = False + point_count = len(polygon_points) + if point_count < 3: + return False + + for index in range(point_count): + x1, y1 = polygon_points[index] + x2, y2 = polygon_points[(index + 1) % point_count] + intersects = ((y1 > y) != (y2 > y)) and ( + x < ((x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12)) + x1 + ) + if intersects: + inside = not inside + + return inside + + +def _orientation(point_a, point_b, point_c): + value = ((point_b[1] - point_a[1]) * (point_c[0] - point_b[0])) - ( + (point_b[0] - point_a[0]) * (point_c[1] - point_b[1]) + ) + if abs(value) < 1e-12: + return 0 + return 1 if value > 0 else 2 + + +def _on_segment(point_a, point_b, point_c): + return ( + min(point_a[0], point_c[0]) - 1e-12 <= point_b[0] <= max(point_a[0], point_c[0]) + 1e-12 + and min(point_a[1], point_c[1]) - 1e-12 <= point_b[1] <= max(point_a[1], point_c[1]) + 1e-12 + ) + + +def segments_intersect(point_a, point_b, point_c, point_d): + orientation_1 = _orientation(point_a, point_b, point_c) + orientation_2 = _orientation(point_a, point_b, point_d) + orientation_3 = _orientation(point_c, point_d, point_a) + orientation_4 = _orientation(point_c, point_d, point_b) + + if orientation_1 != orientation_2 and orientation_3 != orientation_4: + return True + + if orientation_1 == 0 and _on_segment(point_a, point_c, point_b): + return True + if orientation_2 == 0 and _on_segment(point_a, point_d, point_b): + return True + if orientation_3 == 0 and _on_segment(point_c, point_a, point_d): + return True + if orientation_4 == 0 and _on_segment(point_c, point_b, point_d): + return True + + return False + + +def rectangle_contains_point(point, cell_points): + min_lng = min(vertex[0] for vertex in cell_points) + max_lng = max(vertex[0] for vertex in cell_points) + min_lat = min(vertex[1] for vertex in cell_points) + max_lat = max(vertex[1] for vertex in cell_points) + return min_lng <= point[0] <= max_lng and min_lat <= point[1] <= max_lat + + +def polygon_intersects_cell(polygon_points, cell_points): + cell_center = calculate_center(cell_points) + if point_in_polygon([cell_center["longitude"], cell_center["latitude"]], polygon_points): + return True + + if any(point_in_polygon(point, polygon_points) for point in cell_points): + return True + + if any(rectangle_contains_point(point, cell_points) for point in polygon_points): + return True + + polygon_edges = list(zip(polygon_points, polygon_points[1:] + polygon_points[:1])) + cell_edges = list(zip(cell_points, cell_points[1:] + cell_points[:1])) + return any( + segments_intersect(start_a, end_a, start_b, end_b) + for start_a, end_a in polygon_edges + for start_b, end_b in cell_edges + ) + + +def build_square_points(left_lng, bottom_lat, right_lng, top_lat): + return [ + [round(left_lng, 8), round(bottom_lat, 8)], + [round(right_lng, 8), round(bottom_lat, 8)], + [round(right_lng, 8), round(top_lat, 8)], + [round(left_lng, 8), round(top_lat, 8)], + ] + + +def build_zone_square(area_points, center, zone_area_sqm): + if len(area_points) < 4: + return area_points + + width = math.sqrt(max(zone_area_sqm, 1)) + half_width = width / 2.0 + delta_lat = meters_to_latitude_delta(half_width) + delta_lng = meters_to_longitude_delta(half_width, center["latitude"]) + + return build_square_points( + center["longitude"] - delta_lng, + center["latitude"] - delta_lat, + center["longitude"] + delta_lng, + center["latitude"] + delta_lat, + ) + + +def split_area_into_zones(area_feature, cell_side_km=None): + area_ring = get_polygon_ring(area_feature) + area_points = normalize_points(area_ring) + area_center = calculate_center(area_points) + total_area_sqm = polygon_area_sqm(area_ring) + resolved_cell_side_km = get_cell_side_km(cell_side_km) + chunk_area_sqm = get_chunk_area_sqm(resolved_cell_side_km) + cell_side_meters = resolved_cell_side_km * 1000.0 + bbox = get_bbox(area_points) + latitude_step = meters_to_latitude_delta(cell_side_meters) + + zones = [] + sequence = 0 + current_lat = bbox["min_lat"] + + while current_lat < bbox["max_lat"] - 1e-12: + next_lat = current_lat + latitude_step + row_center_lat = current_lat + (latitude_step / 2.0) + longitude_step = meters_to_longitude_delta(cell_side_meters, row_center_lat) + current_lng = bbox["min_lng"] + + while current_lng < bbox["max_lng"] - 1e-12: + next_lng = current_lng + longitude_step + zone_points = build_square_points(current_lng, current_lat, next_lng, next_lat) + + if polygon_intersects_cell(area_points, zone_points): + zone_geometry = { + "type": "Polygon", + "coordinates": [[*zone_points, zone_points[0]]], + } + zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0]) + zones.append( + { + "zone_id": f"zone-{sequence}", + "geometry": zone_geometry, + "points": zone_points, + "center": calculate_center(zone_points), + "area_sqm": round(zone_area_sqm, 2), + "area_hectares": round(zone_area_sqm / 10000, 4), + "sequence": sequence, + } + ) + sequence += 1 + + current_lng = next_lng + + current_lat = next_lat + + if not zones: + zone_points = build_zone_square(area_points, area_center, max(total_area_sqm, chunk_area_sqm)) + zone_geometry = { + "type": "Polygon", + "coordinates": [[*zone_points, zone_points[0]]], + } + zone_area_sqm = polygon_area_sqm(zone_geometry["coordinates"][0]) + zones.append( + { + "zone_id": "zone-0", + "geometry": zone_geometry, + "points": zone_points, + "center": area_center, + "area_sqm": round(zone_area_sqm, 2), + "area_hectares": round(zone_area_sqm / 10000, 4), + "sequence": 0, + } + ) + + zone_count = len(zones) + + area_geometry = { + "type": "Feature", + "properties": {}, + "geometry": deepcopy(area_feature.get("geometry", {})), + } + area_geometry.setdefault("properties", {}) + area_geometry["properties"].update( + { + "center": area_center, + "area_sqm": round(total_area_sqm, 2), + "area_hectares": round(total_area_sqm / 10000, 4), + "cell_side_km": round(resolved_cell_side_km, 4), + } + ) + + return { + "area": { + "geometry": area_geometry, + "points": area_points, + "center": area_center, + "area_sqm": total_area_sqm, + "area_hectares": total_area_sqm / 10000, + "chunk_area_sqm": chunk_area_sqm, + "cell_side_km": resolved_cell_side_km, + "zone_count": zone_count, + }, + "zones": zones, + } + + +def build_rule_based_zone_metrics(index, coords): + if coords: + first_longitude, first_latitude = coords[0] + else: + first_longitude, first_latitude = (0.0, 0.0) + + seed = int((index * 7) + math.floor(first_latitude * 100) + math.floor(first_longitude * 100)) + crop_id = RULE_BASED_CROP_IDS[abs(seed) % len(RULE_BASED_CROP_IDS)] + crop_metadata = RULE_BASED_PRODUCTS[crop_id] + + match_percent = 60 + (abs(seed) % 35) + criteria = [ + {"name": "دما", "value": 55 + (abs(seed + 11) % 40)}, + {"name": "بارش", "value": 55 + (abs(seed + 17) % 40)}, + {"name": "خاک", "value": 55 + (abs(seed + 23) % 40)}, + {"name": "آب", "value": 55 + (abs(seed + 29) % 40)}, + ] + soil_quality_score = criteria[2]["value"] + soil_level = _pick_level(soil_quality_score, 65, 85) + cultivation_risk_score = max(1, min(100, round(100 - match_percent + ((abs(seed) % 9) - 4)))) + cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 60 else "high" + + return { + "soil_quality_score": soil_quality_score, + "soil_level": soil_level, + "water_need_level": crop_metadata["water_need_level"], + "water_need_value": crop_metadata["water_need"], + "cultivation_risk_level": cultivation_risk_level, + "recommended_crop": crop_id, + "match_percent": match_percent, + "estimated_profit": crop_metadata["estimated_profit"], + "reason": crop_metadata["reason"], + "criteria": criteria, + "algorithm": RULE_BASED_ALGORITHM, + } + + +def build_initial_zone_payload(zone): + recommendation = getattr(zone, "recommendation", None) + return { + "zoneId": zone.zone_id, + "geometry": zone.geometry, + "crop": recommendation.product.product_id if recommendation else "", + "matchPercent": recommendation.match_percent if recommendation else 0, + "waterNeed": recommendation.water_need if recommendation else "", + "estimatedProfit": recommendation.estimated_profit if recommendation else "", + } + + +def build_area_zone_payload(zone): + base_payload = _build_area_layer_zone_base_payload(zone) + recommendation = getattr(zone, "recommendation", None) + water_need_layer = getattr(zone, "water_need_layer", None) + soil_quality_layer = getattr(zone, "soil_quality_layer", None) + cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None) + base_payload.update( + { + "crop": recommendation.product.product_id if recommendation else "", + "matchPercent": recommendation.match_percent if recommendation else 0, + "waterNeed": recommendation.water_need if recommendation else "", + "estimatedProfit": recommendation.estimated_profit if recommendation else "", + "waterNeedLayer": { + "level": getattr(water_need_layer, "level", ""), + "value": getattr(water_need_layer, "value", ""), + "color": getattr(water_need_layer, "color", ""), + }, + "soilQualityLayer": { + "level": getattr(soil_quality_layer, "level", ""), + "score": getattr(soil_quality_layer, "score", 0), + "color": getattr(soil_quality_layer, "color", ""), + }, + "cultivationRiskLayer": { + "level": getattr(cultivation_risk_layer, "level", ""), + "color": getattr(cultivation_risk_layer, "color", ""), + }, + } + ) + return base_payload + + +def _build_area_layer_zone_base_payload(zone): + return { + "zoneId": zone.zone_id, + "zoneUuid": str(zone.uuid), + "geometry": zone.geometry, + "center": zone.center, + "area_sqm": zone.area_sqm, + "area_hectares": zone.area_hectares, + "sequence": zone.sequence, + "processing_status": zone.processing_status, + "processing_error": zone.processing_error, + } + + +def build_water_need_area_zone_payload(zone): + base_payload = _build_area_layer_zone_base_payload(zone) + water_need_layer = getattr(zone, "water_need_layer", None) + base_payload["waterNeedLayer"] = { + "level": getattr(water_need_layer, "level", ""), + "value": getattr(water_need_layer, "value", ""), + "color": getattr(water_need_layer, "color", ""), + } + return base_payload + + +def build_soil_quality_area_zone_payload(zone): + base_payload = _build_area_layer_zone_base_payload(zone) + soil_quality_layer = getattr(zone, "soil_quality_layer", None) + base_payload["soilQualityLayer"] = { + "level": getattr(soil_quality_layer, "level", ""), + "score": getattr(soil_quality_layer, "score", 0), + "color": getattr(soil_quality_layer, "color", ""), + } + return base_payload + + +def build_cultivation_risk_area_zone_payload(zone): + base_payload = _build_area_layer_zone_base_payload(zone) + cultivation_risk_layer = getattr(zone, "cultivation_risk_layer", None) + base_payload["cultivationRiskLayer"] = { + "level": getattr(cultivation_risk_layer, "level", ""), + "color": getattr(cultivation_risk_layer, "color", ""), + } + return base_payload + + +def persist_zone_analysis_metrics(zone, metrics): + ensure_products_exist() + product = CropProduct.objects.get(product_id=metrics["recommended_crop"]) + recommendation, _ = CropZoneRecommendation.objects.update_or_create( + crop_zone=zone, + defaults={ + "product": product, + "match_percent": metrics["match_percent"], + "water_need": metrics["water_need_value"], + "estimated_profit": metrics["estimated_profit"], + "reason": metrics["reason"], + }, + ) + CropZoneCriteria.objects.filter(recommendation=recommendation).delete() + CropZoneCriteria.objects.bulk_create( + [ + CropZoneCriteria( + recommendation=recommendation, + name=item["name"], + value=item["value"], + sequence=index, + ) + for index, item in enumerate(metrics["criteria"]) + ] + ) + CropZoneWaterNeedLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["water_need_level"], + "value": metrics["water_need_value"], + "color": _get_level_color_map("water", metrics["water_need_level"]), + }, + ) + CropZoneSoilQualityLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["soil_level"], + "score": metrics["soil_quality_score"], + "color": _get_level_color_map("soil", metrics["soil_level"]), + }, + ) + CropZoneCultivationRiskLayer.objects.update_or_create( + crop_zone=zone, + defaults={ + "level": metrics["cultivation_risk_level"], + "color": _get_level_color_map("risk", metrics["cultivation_risk_level"]), + }, + ) + return recommendation + + +def ensure_rule_based_zone_data(zone, force=False): + has_recommendation = CropZoneRecommendation.objects.filter(crop_zone=zone).exists() + if has_recommendation and not force: + return zone + + metrics = build_rule_based_zone_metrics(zone.sequence, zone.points) + persist_zone_analysis_metrics(zone, metrics) + return zone + + +def _get_level_color_map(layer_name, level): + mappings = { + "water": {"low": "#7dd3fc", "medium": "#0ea5e9", "high": "#0369a1"}, + "soil": {"low": "#ef4444", "medium": "#eab308", "high": "#22c55e"}, + "risk": {"low": "#22c55e", "medium": "#f59e0b", "high": "#ef4444"}, + } + return mappings[layer_name][level] + + +def _pick_level(score, low_threshold, high_threshold): + if score >= high_threshold: + return "high" + if score >= low_threshold: + return "medium" + return "low" + + +def _format_range(start, end, suffix): + return f"{start}-{end} {suffix}" + + +def _derive_analysis_metrics(depths): + if not depths: + return { + "soil_quality_score": 0, + "soil_level": "low", + "water_need_level": "high", + "water_need_value": "0-0 m³/ha", + "cultivation_risk_level": "high", + "recommended_crop": PRODUCT_DEFAULTS[0]["id"], + "match_percent": 0, + "estimated_profit": "0-0 میلیون/هکتار", + "reason": "داده تحلیل خاک موجود نیست", + "criteria": [], + } + + avg_ph = sum(item.get("phh2o", 0) for item in depths) / len(depths) + avg_soc = sum(item.get("soc", 0) for item in depths) / len(depths) + avg_clay = sum(item.get("clay", 0) for item in depths) / len(depths) + avg_nitrogen = sum(item.get("nitrogen", 0) for item in depths) / len(depths) + avg_wv0033 = sum(item.get("wv0033", 0) for item in depths) / len(depths) + + soil_quality_score = max(0, min(100, round((avg_soc * 20) + (avg_nitrogen * 120) + (avg_wv0033 * 120) + (20 - abs(avg_ph - 7) * 10)))) + soil_level = _pick_level(soil_quality_score, 50, 80) + + water_base = round(3000 + (avg_clay * 70)) + water_need_value = _format_range(water_base, water_base + 1000, "m³/ha") + water_need_level = "low" if water_base < 4000 else "medium" if water_base < 5000 else "high" + + cultivation_risk_score = max(0, min(100, round(100 - soil_quality_score + abs(avg_ph - 7) * 8))) + cultivation_risk_level = "low" if cultivation_risk_score <= 30 else "medium" if cultivation_risk_score <= 55 else "high" + + if water_need_level == "low" and soil_quality_score >= 85: + recommended_crop = "saffron" + estimated_profit = "۵۰-۱۵۰ میلیون/هکتار" + elif soil_quality_score >= 70: + recommended_crop = "wheat" + estimated_profit = "۱۵-۲۵ میلیون/هکتار" + else: + recommended_crop = "canola" + estimated_profit = "۲۰-۳۵ میلیون/هکتار" + + match_percent = max(1, min(100, round((soil_quality_score * 0.55) + ((100 - cultivation_risk_score) * 0.45)))) + reason = "خاک و شرایط رطوبتی این زون برای محصول پیشنهادی مناسب ارزیابی شده است" + criteria = [ + {"name": "دما", "value": max(1, min(100, round(70 + (avg_ph - 6.5) * 10)))}, + {"name": "بارش", "value": max(1, min(100, round(60 + avg_wv0033 * 100)))}, + {"name": "خاک", "value": soil_quality_score}, + {"name": "آب", "value": max(1, min(100, round(100 - ((water_base - 3000) / 30))))}, + ] + + return { + "soil_quality_score": soil_quality_score, + "soil_level": soil_level, + "water_need_level": water_need_level, + "water_need_value": water_need_value, + "cultivation_risk_level": cultivation_risk_level, + "recommended_crop": recommended_crop, + "match_percent": match_percent, + "estimated_profit": estimated_profit, + "reason": reason, + "criteria": criteria, + } + + +def fetch_soil_data_for_zone(zone): + center = zone.center or calculate_center(zone.points) + payload = { + "lon": center["longitude"], + "lat": center["latitude"], + "zone": { + "id": zone.zone_id, + "geometry": zone.geometry, + "center": center, + "area_sqm": zone.area_sqm, + "area_hectares": zone.area_hectares, + }, + } + return external_request("ai", "/soil-data", method="POST", payload=payload).data + + +def analyze_and_store_zone_soil_data(zone_id): + ensure_products_exist() + zone = CropZone.objects.select_related("crop_area").get(id=zone_id) + if zone.processing_status == CropZone.STATUS_COMPLETED: + return zone + + zone.processing_status = CropZone.STATUS_PROCESSING + zone.processing_error = "" + zone.save(update_fields=["processing_status", "processing_error", "updated_at"]) + + try: + adapter_data = fetch_soil_data_for_zone(zone) + soil_data = adapter_data.get("data", {}) if isinstance(adapter_data, dict) else {} + depths = soil_data.get("depths", []) + metrics = _derive_analysis_metrics(depths) + product = CropProduct.objects.get(product_id=metrics["recommended_crop"]) + + CropZoneAnalysis.objects.update_or_create( + crop_zone=zone, + defaults={ + "source": soil_data.get("source", ""), + "external_record_id": str(soil_data.get("id", "")), + "longitude": Decimal(str(soil_data.get("lon", zone.center.get("longitude", 0)))), + "latitude": Decimal(str(soil_data.get("lat", zone.center.get("latitude", 0)))), + "raw_response": adapter_data if isinstance(adapter_data, dict) else {}, + "depths": depths, + }, + ) + persist_zone_analysis_metrics(zone, metrics) + zone.processing_status = CropZone.STATUS_COMPLETED + zone.processing_error = "" + zone.save(update_fields=["processing_status", "processing_error", "updated_at"]) + except Exception as exc: + zone.processing_status = CropZone.STATUS_FAILED + zone.processing_error = str(exc) + zone.save(update_fields=["processing_status", "processing_error", "updated_at"]) + raise + + return zone + + +def _get_stale_zone_ids(zones): + completed_task_ids = { + zone.task_id + for zone in zones + if zone.processing_status == CropZone.STATUS_COMPLETED and zone.task_id + } + stale_before = timezone.now() - timedelta(seconds=get_task_stale_seconds()) + stale_zone_ids = [] + + for zone in zones: + if zone.processing_status == CropZone.STATUS_COMPLETED or not zone.task_id: + continue + if zone.task_id in completed_task_ids: + stale_zone_ids.append(zone.id) + continue + if zone.updated_at > stale_before: + continue + + try: + task_state = AsyncResult(zone.task_id).state + except Exception: + task_state = TASK_STATE_PENDING + + if task_state in { + TASK_STATE_PENDING, + TASK_STATE_SUCCESS, + TASK_STATE_FAILURE, + TASK_STATE_REVOKED, + }: + stale_zone_ids.append(zone.id) + + return stale_zone_ids + + +def dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None, force=False): + from .tasks import process_zone_soil_data + + queryset = CropZone.objects.all() + if crop_area_id is not None: + queryset = queryset.filter(crop_area_id=crop_area_id) + if zone_ids is not None: + queryset = queryset.filter(id__in=zone_ids) + + zones = list(queryset.only("id", "task_id", "processing_status").order_by("sequence", "id")) + for zone in zones: + if zone.processing_status == CropZone.STATUS_COMPLETED: + continue + if not force and zone.processing_status == CropZone.STATUS_PROCESSING and zone.task_id: + continue + if not force and zone.processing_status == CropZone.STATUS_PENDING and zone.task_id: + continue + + try: + async_result = process_zone_soil_data.delay(zone.id) + task_identifier = getattr(async_result, "id", "") or str(uuid.uuid4()) + processing_error = "" + except OperationalError as exc: + task_identifier = str(uuid.uuid4()) + processing_error = f"Celery broker unavailable: {exc}" + except Exception as exc: + task_identifier = str(uuid.uuid4()) + processing_error = f"Celery dispatch failed: {exc}" + + update_fields = { + "task_id": task_identifier, + "processing_status": CropZone.STATUS_PENDING, + } + update_fields["processing_error"] = processing_error + CropZone.objects.filter(id=zone.id).update(**update_fields) + + +def create_missing_zones_for_area(crop_area): + if crop_area.zones.exists(): + return list(crop_area.zones.order_by("sequence", "id")) + + area_feature = normalize_area_feature(crop_area.geometry) + zoning_result = split_area_into_zones( + area_feature, + cell_side_km=math.sqrt(max(crop_area.chunk_area_sqm, 1)) / 1000.0, + ) + zones = CropZone.objects.bulk_create( + [ + CropZone( + crop_area=crop_area, + zone_id=zone["zone_id"], + geometry=zone["geometry"], + points=zone["points"], + center=zone["center"], + area_sqm=round(zone["area_sqm"], 2), + area_hectares=round(zone["area_hectares"], 4), + sequence=zone["sequence"], + ) + for zone in zoning_result["zones"] + ] + ) + crop_area.zone_count = len(zones) + crop_area.save(update_fields=["zone_count", "updated_at"]) + return list(crop_area.zones.order_by("sequence", "id")) + + +def get_farm_for_uuid(farm_uuid, owner=None): + if not farm_uuid: + raise ValueError("farm_uuid is required.") + + filters = {"farm_uuid": farm_uuid} + if owner is not None: + filters["owner"] = owner + + try: + return FarmHub.objects.get(**filters) + except FarmHub.DoesNotExist as exc: + raise ValueError("Farm not found.") from exc + + +def ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None, owner=None): + farm = get_farm_for_uuid(farm_uuid, owner=owner) + latest_area = CropArea.objects.filter(farm=farm).order_by("-created_at", "-id").first() + if latest_area is None: + latest_area, _ = create_zones_and_dispatch(area_feature or get_default_area_feature(), farm=farm) + return latest_area + + zones = create_missing_zones_for_area(latest_area) + for zone in zones: + ensure_rule_based_zone_data(zone) + + stale_zone_ids = _get_stale_zone_ids(zones) + zones_to_dispatch = [ + zone.id + for zone in zones + if zone.processing_status != CropZone.STATUS_COMPLETED + and zone.id not in stale_zone_ids + and not (zone.processing_status in {CropZone.STATUS_PENDING, CropZone.STATUS_PROCESSING} and zone.task_id) + ] + + if stale_zone_ids: + dispatch_zone_processing_tasks(zone_ids=stale_zone_ids, force=True) + if zones_to_dispatch: + dispatch_zone_processing_tasks(zone_ids=zones_to_dispatch) + + return CropArea.objects.get(id=latest_area.id) + + +def create_zones_and_dispatch(area_feature, cell_side_km=None, farm=None): + ensure_products_exist() + area_feature = normalize_area_feature(area_feature) + zoning_result = split_area_into_zones(area_feature, cell_side_km=cell_side_km) + area_data = zoning_result["area"] + + with transaction.atomic(): + crop_area = CropArea.objects.create( + farm=farm, + geometry=area_data["geometry"], + points=area_data["points"], + center=area_data["center"], + area_sqm=round(area_data["area_sqm"], 2), + area_hectares=round(area_data["area_hectares"], 4), + chunk_area_sqm=round(area_data["chunk_area_sqm"], 2), + zone_count=area_data["zone_count"], + ) + zones = CropZone.objects.bulk_create( + [ + CropZone( + crop_area=crop_area, + zone_id=zone["zone_id"], + geometry=zone["geometry"], + points=zone["points"], + center=zone["center"], + area_sqm=round(zone["area_sqm"], 2), + area_hectares=round(zone["area_hectares"], 4), + sequence=zone["sequence"], + ) + for zone in zoning_result["zones"] + ] + ) + + crop_area.refresh_from_db() + zones = list(crop_area.zones.order_by("sequence", "id")) + for zone in zones: + ensure_rule_based_zone_data(zone) + dispatch_zone_processing_tasks(crop_area.id) + return crop_area, zones + + +def _zones_queryset(zone_ids=None): + queryset = CropZone.objects.select_related( + "recommendation__product", + "water_need_layer", + "soil_quality_layer", + "cultivation_risk_layer", + ).prefetch_related( + Prefetch("recommendation__criteria", queryset=CropZoneCriteria.objects.order_by("sequence", "id")) + ).order_by("sequence", "id") + if zone_ids: + queryset = queryset.filter(zone_id__in=zone_ids) + return queryset + + +def _get_idle_area_payload(page, page_size): + return { + "task": { + "status": "IDLE", + "area_uuid": "", + "total_zones": 0, + "completed_zones": 0, + "processing_zones": 0, + "pending_zones": 0, + "failed_zones": 0, + "failed_zone_errors": [], + "cell_side_km": round(get_default_cell_side_km(), 4), + }, + "area": get_default_area_feature(), + "zones": [], + "pagination": { + "page": page, + "page_size": page_size, + "total_pages": 0, + "total_zones": 0, + "returned_zones": 0, + "has_next": False, + "has_previous": False, + }, + } + + +def _build_latest_area_layer_payload(zone_builder, area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): + area = area or CropArea.objects.order_by("-created_at", "-id").first() + if not area: + return _get_idle_area_payload(page, page_size) + + status_zones = list(area.zones.only("zone_id", "task_id", "processing_status", "processing_error")) + total_zones = len(status_zones) + completed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_COMPLETED) + processing_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PROCESSING) + failed_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_FAILED) + pending_zones = sum(1 for zone in status_zones if zone.processing_status == CropZone.STATUS_PENDING) + total_pages = math.ceil(total_zones / page_size) if total_zones else 0 + start_index = (page - 1) * page_size + end_index = start_index + page_size + zones = list(_zones_queryset().filter(crop_area=area)[start_index:end_index]) + + if failed_zones: + task_status = "FAILURE" + elif total_zones and completed_zones == total_zones: + task_status = "SUCCESS" + elif processing_zones or completed_zones: + task_status = "PROCESSING" + else: + task_status = "PENDING" + + current_stage = "waiting_to_start" + if failed_zones: + current_stage = "failed" + elif total_zones and completed_zones == total_zones: + current_stage = "completed" + elif processing_zones: + current_stage = "processing_zones" + elif pending_zones and completed_zones: + current_stage = "continuing_processing" + elif pending_zones: + current_stage = "queued" + + progress_percent = 0 + if total_zones: + progress_percent = round((completed_zones / total_zones) * 100, 2) + + return { + "task": { + "status": task_status, + "stage": current_stage, + "stage_label": { + "waiting_to_start": "در انتظار شروع پردازش", + "queued": "تسک ساخته شده و در صف پردازش است", + "processing_zones": "در حال پردازش زون‌ها", + "continuing_processing": "بخشی از زون‌ها پردازش شده و بقیه در صف هستند", + "completed": "پردازش همه زون‌ها کامل شده است", + "failed": "پردازش بعضی زون‌ها با خطا مواجه شده است", + }[current_stage], + "area_uuid": str(area.uuid), + "total_zones": total_zones, + "completed_zones": completed_zones, + "processing_zones": processing_zones, + "pending_zones": pending_zones, + "failed_zones": failed_zones, + "remaining_zones": max(total_zones - completed_zones, 0), + "progress_percent": progress_percent, + "summary": { + "done": completed_zones, + "in_progress": processing_zones, + "remaining": pending_zones, + "failed": failed_zones, + }, + "message": f"از مجموع {total_zones} زون، {completed_zones} زون پردازش شده، {processing_zones} زون در حال پردازش و {pending_zones} زون باقی مانده است.", + "failed_zone_errors": [ + { + "zoneId": zone.zone_id, + "error": zone.processing_error, + } + for zone in status_zones + if zone.processing_status == CropZone.STATUS_FAILED and zone.processing_error + ], + "cell_side_km": round(math.sqrt(max(area.chunk_area_sqm, 1)) / 1000.0, 4), + }, + "area": area.geometry, + "zones": [zone_builder(zone) for zone in zones], + "pagination": { + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "total_zones": total_zones, + "returned_zones": len(zones), + "has_next": page < total_pages, + "has_previous": page > 1 and total_pages > 0, + }, + } + + +def get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): + return _build_latest_area_layer_payload(build_area_zone_payload, area=area, page=page, page_size=page_size) + + +def get_latest_water_need_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): + return _build_latest_area_layer_payload( + build_water_need_area_zone_payload, + area=area, + page=page, + page_size=page_size, + ) + + +def get_latest_soil_quality_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): + return _build_latest_area_layer_payload( + build_soil_quality_area_zone_payload, + area=area, + page=page, + page_size=page_size, + ) + + +def get_latest_cultivation_risk_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE): + return _build_latest_area_layer_payload( + build_cultivation_risk_area_zone_payload, + area=area, + page=page, + page_size=page_size, + ) + + +def get_initial_zones_payload(crop_area): + zones = _zones_queryset().filter(crop_area=crop_area) + return { + "total_area_hectares": crop_area.area_hectares, + "total_area_sqm": crop_area.area_sqm, + "zone_count": crop_area.zone_count, + "zones": [build_initial_zone_payload(zone) for zone in zones], + } + + +def get_water_need_payload(zone_ids=None): + zones = _zones_queryset(zone_ids) + return { + "zones": [ + { + "zoneId": zone.zone_id, + "geometry": zone.geometry, + "level": getattr(zone.water_need_layer, "level", ""), + "value": getattr(zone.water_need_layer, "value", ""), + "color": getattr(zone.water_need_layer, "color", ""), + } + for zone in zones + ] + } + + +def get_soil_quality_payload(zone_ids=None): + zones = _zones_queryset(zone_ids) + return { + "zones": [ + { + "zoneId": zone.zone_id, + "geometry": zone.geometry, + "level": getattr(zone.soil_quality_layer, "level", ""), + "score": getattr(zone.soil_quality_layer, "score", 0), + "color": getattr(zone.soil_quality_layer, "color", ""), + } + for zone in zones + ] + } + + +def get_cultivation_risk_payload(zone_ids=None): + zones = _zones_queryset(zone_ids) + return { + "zones": [ + { + "zoneId": zone.zone_id, + "geometry": zone.geometry, + "level": getattr(zone.cultivation_risk_layer, "level", ""), + "color": getattr(zone.cultivation_risk_layer, "color", ""), + } + for zone in zones + ] + } + + +def get_zone_details_payload(zone_id): + zone = _zones_queryset().get(zone_id=zone_id) + recommendation = getattr(zone, "recommendation", None) + criteria = recommendation.criteria.all() if recommendation else [] + return { + "zoneId": zone.zone_id, + "crop": recommendation.product.product_id if recommendation else "", + "matchPercent": recommendation.match_percent if recommendation else 0, + "waterNeed": recommendation.water_need if recommendation else "", + "estimatedProfit": recommendation.estimated_profit if recommendation else "", + "reason": recommendation.reason if recommendation else "", + "criteria": [{"name": item.name, "value": item.value} for item in criteria], + "area_hectares": zone.area_hectares, + } diff --git a/Modules/Backend/crop_zoning/tasks.py b/Modules/Backend/crop_zoning/tasks.py new file mode 100644 index 0000000..83af005 --- /dev/null +++ b/Modules/Backend/crop_zoning/tasks.py @@ -0,0 +1,17 @@ +from celery import shared_task +from django.db import transaction + +from .services import analyze_and_store_zone_soil_data + + +@shared_task( + bind=True, + autoretry_for=(Exception,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 3}, +) +def process_zone_soil_data(self, zone_id): + with transaction.atomic(): + analyze_and_store_zone_soil_data(zone_id=zone_id) + return {"zone_id": zone_id, "status": "processed"} diff --git a/Modules/Backend/crop_zoning/tests.py b/Modules/Backend/crop_zoning/tests.py new file mode 100644 index 0000000..5ce6089 --- /dev/null +++ b/Modules/Backend/crop_zoning/tests.py @@ -0,0 +1,419 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.utils import timezone +from kombu.exceptions import OperationalError +from rest_framework.test import APIRequestFactory, force_authenticate + +from crop_zoning.models import CropArea, CropZone +from crop_zoning.views import ( + AreaView, + CultivationRiskView, + SoilQualityView, + WaterNeedView, + ZonesInitialView, +) +from farm_hub.models import FarmHub, FarmType + + +AREA_GEOJSON = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815], + ] + ], + }, +} + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class ZonesInitialViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + + def test_post_accepts_area_geojson_alias(self): + request = self.factory.post( + "/api/crop-zoning/zones/initial/", + {"area_geojson": AREA_GEOJSON}, + format="json", + ) + + response = ZonesInitialView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertGreater(response.data["data"]["zone_count"], 1) + self.assertEqual( + response.data["data"]["zone_count"], + len(response.data["data"]["zones"]), + ) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class AreaViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, name="farm-1", farm_type=self.farm_type) + self.other_farm = FarmHub.objects.create(owner=self.other_user, name="farm-2", farm_type=self.farm_type) + + def _create_area(self, **kwargs): + defaults = { + "farm": self.farm, + "geometry": AREA_GEOJSON, + "points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + "center": {"longitude": 51.40874867, "latitude": 35.69575533}, + "area_sqm": 300000, + "area_hectares": 30, + "chunk_area_sqm": 200000, + "zone_count": 2, + } + defaults.update(kwargs) + return CropArea.objects.create(**defaults) + + def _request(self): + request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + return request + + def _request_with_pagination(self, page=1, page_size=10): + request = self.factory.get( + f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}&page={page}&page_size={page_size}" + ) + force_authenticate(request, user=self.user) + return request + + def test_get_requires_farm_uuid(self): + request = self.factory.get("/api/crop-zoning/area/") + force_authenticate(request, user=self.user) + response = AreaView.as_view()(request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "farm_uuid is required.") + + def test_get_rejects_foreign_farm_uuid(self): + request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.other_farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = AreaView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "Farm not found.") + + def test_get_returns_pending_task_status_until_all_zones_complete(self): + crop_area = self._create_area() + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="celery-task-1", + ) + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-1", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4088, "latitude": 35.6958}, + area_sqm=100000, + area_hectares=10, + sequence=1, + processing_status=CropZone.STATUS_PROCESSING, + task_id="celery-task-1", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING") + self.assertEqual(response.data["data"]["task"]["total_zones"], 2) + self.assertEqual(response.data["data"]["area"], AREA_GEOJSON) + self.assertEqual(len(response.data["data"]["zones"]), 2) + self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-0") + self.assertIn("processing_status", response.data["data"]["zones"][0]) + + def test_get_returns_area_when_all_tasks_complete(self): + crop_area = self._create_area() + for sequence in range(2): + CropZone.objects.create( + crop_area=crop_area, + zone_id=f"zone-{sequence}", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957}, + area_sqm=150000, + area_hectares=15, + sequence=sequence, + processing_status=CropZone.STATUS_COMPLETED, + task_id="celery-task-1", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS") + self.assertEqual(response.data["data"]["area"], AREA_GEOJSON) + self.assertEqual(len(response.data["data"]["zones"]), 2) + self.assertEqual(response.data["data"]["zones"][1]["zoneId"], "zone-1") + self.assertIn("crop", response.data["data"]["zones"][0]) + self.assertIn("waterNeedLayer", response.data["data"]["zones"][0]) + + def test_get_returns_paginated_zones(self): + crop_area = self._create_area(zone_count=3, area_sqm=300000, area_hectares=30) + for sequence in range(3): + CropZone.objects.create( + crop_area=crop_area, + zone_id=f"zone-{sequence}", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957}, + area_sqm=100000, + area_hectares=10, + sequence=sequence, + processing_status=CropZone.STATUS_COMPLETED, + task_id=f"celery-task-{sequence}", + ) + + response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["data"]["zones"]), 1) + self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-1") + self.assertEqual(response.data["data"]["pagination"]["page"], 2) + self.assertEqual(response.data["data"]["pagination"]["page_size"], 1) + self.assertEqual(response.data["data"]["pagination"]["total_pages"], 3) + self.assertTrue(response.data["data"]["pagination"]["has_next"]) + self.assertTrue(response.data["data"]["pagination"]["has_previous"]) + + def test_get_rejects_invalid_pagination_params(self): + response = AreaView.as_view()(self._request_with_pagination(page=0, page_size=10)) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "page must be a positive integer.") + + @patch("crop_zoning.services.dispatch_zone_processing_tasks") + def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch): + crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20) + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + mock_dispatch.assert_called_once() + + @patch("crop_zoning.services.create_zones_and_dispatch") + def test_get_creates_area_when_farm_has_no_data(self, mock_create): + created_area = self._create_area(zone_count=0) + mock_create.return_value = (created_area, []) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + mock_create.assert_called_once() + self.assertEqual(mock_create.call_args.kwargs["farm"], self.farm) + + @patch("crop_zoning.tasks.process_zone_soil_data.delay") + def test_each_zone_gets_its_own_task(self, mock_delay): + crop_area = self._create_area() + zone0 = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + zone1 = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-1", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4088, "latitude": 35.6958}, + area_sqm=100000, + area_hectares=10, + sequence=1, + processing_status=CropZone.STATUS_PENDING, + task_id="", + ) + + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + self.assertEqual(mock_delay.call_count, 2) + zone0.refresh_from_db() + zone1.refresh_from_db() + self.assertTrue(zone0.task_id) + self.assertTrue(zone1.task_id) + self.assertNotEqual(zone0.task_id, zone1.task_id) + + @patch("crop_zoning.services.AsyncResult") + def test_stale_tasks_are_redispatched(self, mock_async_result): + crop_area = self._create_area() + stale_time = timezone.now() - timedelta(minutes=10) + stale_zone = CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=200000, + area_hectares=20, + sequence=0, + processing_status=CropZone.STATUS_PROCESSING, + task_id="stale-task", + ) + CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time) + + mock_async_result.side_effect = OperationalError("broker down") + + with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch: + response = AreaView.as_view()(self._request()) + + self.assertEqual(response.status_code, 200) + mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class LayerAreaViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="layer-farmer", + password="secret123", + email="layer@example.com", + phone_number="09120000002", + ) + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create(owner=self.user, name="layer-farm", farm_type=self.farm_type) + + def _create_area(self, **kwargs): + defaults = { + "farm": self.farm, + "geometry": AREA_GEOJSON, + "points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + "center": {"longitude": 51.40874867, "latitude": 35.69575533}, + "area_sqm": 300000, + "area_hectares": 30, + "chunk_area_sqm": 200000, + "zone_count": 1, + } + defaults.update(kwargs) + return CropArea.objects.create(**defaults) + + def _create_completed_zone(self): + crop_area = self._create_area() + CropZone.objects.create( + crop_area=crop_area, + zone_id="zone-0", + geometry=AREA_GEOJSON["geometry"], + points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1], + center={"longitude": 51.4087, "latitude": 35.6957}, + area_sqm=300000, + area_hectares=30, + sequence=0, + processing_status=CropZone.STATUS_COMPLETED, + task_id="celery-task-1", + ) + return crop_area + + def _request(self, path): + request = self.factory.get(f"{path}?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + return request + + def test_water_need_view_requires_farm_uuid(self): + request = self.factory.get("/api/crop-zoning/water-need/") + force_authenticate(request, user=self.user) + + response = WaterNeedView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["message"], "farm_uuid is required.") + + def test_water_need_view_returns_area_style_payload(self): + self._create_completed_zone() + + response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS") + self.assertEqual(response.data["data"]["area"], AREA_GEOJSON) + self.assertEqual(len(response.data["data"]["zones"]), 1) + self.assertIn("waterNeedLayer", response.data["data"]["zones"][0]) + self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0]) + self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0]) + + def test_soil_quality_view_returns_area_style_payload(self): + self._create_completed_zone() + + response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS") + self.assertEqual(len(response.data["data"]["zones"]), 1) + self.assertIn("soilQualityLayer", response.data["data"]["zones"][0]) + self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0]) + self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0]) + + def test_cultivation_risk_view_returns_area_style_payload(self): + self._create_completed_zone() + + response = CultivationRiskView.as_view()(self._request("/api/crop-zoning/cultivation-risk/")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS") + self.assertEqual(len(response.data["data"]["zones"]), 1) + self.assertIn("cultivationRiskLayer", response.data["data"]["zones"][0]) + self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0]) + self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0]) diff --git a/Modules/Backend/crop_zoning/urls.py b/Modules/Backend/crop_zoning/urls.py new file mode 100644 index 0000000..23f485d --- /dev/null +++ b/Modules/Backend/crop_zoning/urls.py @@ -0,0 +1,43 @@ +from django.urls import path + +from .views import ( + AreaView, + CultivationRiskView, + ProductsView, + SoilQualityView, + WaterNeedView, + ZoneDetailsView, + ZonesCultivationRiskView, + ZonesInitialView, + ZonesSoilQualityView, + ZonesWaterNeedView, +) + +urlpatterns = [ + path("area/", AreaView.as_view(), name="crop-zoning-area"), + path("water-need/", WaterNeedView.as_view(), name="crop-zoning-water-need"), + path("soil-quality/", SoilQualityView.as_view(), name="crop-zoning-soil-quality"), + path("cultivation-risk/", CultivationRiskView.as_view(), name="crop-zoning-cultivation-risk"), + path("products/", ProductsView.as_view(), name="crop-zoning-products"), + # path("zones/initial/", ZonesInitialView.as_view(), name="crop-zoning-zones-initial"), + # path( + # "zones/water-need/", + # ZonesWaterNeedView.as_view(), + # name="crop-zoning-zones-water-need", + # ), + # path( + # "zones/soil-quality/", + # ZonesSoilQualityView.as_view(), + # name="crop-zoning-zones-soil-quality", + # ), + # path( + # "zones/cultivation-risk/", + # ZonesCultivationRiskView.as_view(), + # name="crop-zoning-zones-cultivation-risk", + # ), + path( + "zones//details/", + ZoneDetailsView.as_view(), + name="crop-zoning-zone-details", + ), +] diff --git a/Modules/Backend/crop_zoning/views.py b/Modules/Backend/crop_zoning/views.py new file mode 100644 index 0000000..8dc936c --- /dev/null +++ b/Modules/Backend/crop_zoning/views.py @@ -0,0 +1,215 @@ +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema + +from config.swagger import status_response +from .services import ( + create_zones_and_dispatch, + ensure_latest_area_ready_for_processing, + get_latest_cultivation_risk_payload, + get_cultivation_risk_payload, + get_default_area_feature, + get_initial_zones_payload, + get_latest_area_payload, + get_latest_soil_quality_payload, + get_latest_water_need_payload, + get_products_payload, + get_soil_quality_payload, + get_water_need_payload, + get_zone_details_payload, + get_zone_page_request_params, +) + + +AREA_QUERY_PARAMETERS = [ + OpenApiParameter( + name="farm_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + required=True, + description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.", + default="11111111-1111-1111-1111-111111111111"), + OpenApiParameter( + name="page", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="شماره صفحه زون ها. مقدار پيش فرض 1 است.", + ), + OpenApiParameter( + name="page_size", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.", + ), +] + + +class BaseAreaDataView(APIView): + payload_getter = None + + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + try: + page, page_size = get_zone_page_request_params(request.query_params) + crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user) + except ValueError as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except ImproperlyConfigured as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response( + {"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)}, + status=status.HTTP_200_OK, + ) + + +class AreaView(BaseAreaDataView): + payload_getter = staticmethod(get_latest_area_payload) + + @extend_schema( + tags=["Crop Zoning"], + parameters=AREA_QUERY_PARAMETERS, + responses={ + 200: status_response("CropZoningAreaResponse", data=serializers.JSONField()), + 400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()), + 500: status_response("CropZoningAreaServerError", data=serializers.JSONField()), + }, + ) + def get(self, request): + return super().get(request) + + +class WaterNeedView(BaseAreaDataView): + payload_getter = staticmethod(get_latest_water_need_payload) + + @extend_schema( + tags=["Crop Zoning"], + parameters=AREA_QUERY_PARAMETERS, + responses={ + 200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()), + 400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()), + 500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()), + }, + ) + def get(self, request): + return super().get(request) + + +class SoilQualityView(BaseAreaDataView): + payload_getter = staticmethod(get_latest_soil_quality_payload) + + @extend_schema( + tags=["Crop Zoning"], + parameters=AREA_QUERY_PARAMETERS, + responses={ + 200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()), + 400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()), + 500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()), + }, + ) + def get(self, request): + return super().get(request) + + +class CultivationRiskView(BaseAreaDataView): + payload_getter = staticmethod(get_latest_cultivation_risk_payload) + + @extend_schema( + tags=["Crop Zoning"], + parameters=AREA_QUERY_PARAMETERS, + responses={ + 200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()), + 400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()), + 500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()), + }, + ) + def get(self, request): + return super().get(request) + + +class ProductsView(APIView): + @extend_schema( + tags=["Crop Zoning"], + responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())}, + ) + def get(self, request): + return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK) + + +class ZonesInitialView(APIView): + @extend_schema( + tags=["Crop Zoning"], + request=OpenApiTypes.OBJECT, + responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())}, + ) + def post(self, request): + area_feature = ( + request.data.get("area") + or request.data.get("area_geojson") + or request.data.get("boundary") + or get_default_area_feature() + ) + cell_side_km = request.data.get("cell_side_km") + + try: + crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km) + except ValueError as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except ImproperlyConfigured as exc: + return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK) + + +class ZonesWaterNeedView(APIView): + @extend_schema( + tags=["Crop Zoning"], + request=OpenApiTypes.OBJECT, + responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())}, + ) + def post(self, request): + zone_ids = request.data.get("zoneIds") + return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK) + + +class ZonesSoilQualityView(APIView): + @extend_schema( + tags=["Crop Zoning"], + request=OpenApiTypes.OBJECT, + responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())}, + ) + def post(self, request): + zone_ids = request.data.get("zoneIds") + return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK) + + +class ZonesCultivationRiskView(APIView): + @extend_schema( + tags=["Crop Zoning"], + request=OpenApiTypes.OBJECT, + responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())}, + ) + def post(self, request): + zone_ids = request.data.get("zoneIds") + return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK) + + +class ZoneDetailsView(APIView): + @extend_schema( + tags=["Crop Zoning"], + responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())}, + ) + def get(self, request, zone_id): + try: + data = get_zone_details_payload(zone_id) + except Exception as exc: + if exc.__class__.__name__ == "DoesNotExist": + raise Http404("Zone not found") + raise + return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/dashboard/__init__.py b/Modules/Backend/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/dashboard/apps.py b/Modules/Backend/dashboard/apps.py new file mode 100644 index 0000000..b2a37fc --- /dev/null +++ b/Modules/Backend/dashboard/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "dashboard" + verbose_name = "Farm Dashboard" diff --git a/Modules/Backend/dashboard/defaults.py b/Modules/Backend/dashboard/defaults.py new file mode 100644 index 0000000..220f2c1 --- /dev/null +++ b/Modules/Backend/dashboard/defaults.py @@ -0,0 +1,42 @@ +from copy import deepcopy + + +VALID_ROW_IDS = [ + "overviewKpis", + "weatherAlerts", + "sensorMonitoring", + "sensorCharts", + "alertsWater", + "predictions", + "soilHeatmap", + "ndviRecommendations", + "economic", +] + +VALID_CARD_IDS = [ + "farmOverviewKpis", + "farmWeatherCard", + "farmAlertsTracker", + "sensorValuesList", + "sensorRadarChart", + "sensorComparisonChart", + "anomalyDetectionCard", + "farmAlertsTimeline", + "waterNeedPrediction", + "harvestPredictionCard", + "yieldPredictionChart", + "soilMoistureHeatmap", + "ndviHealthCard", + "recommendationsList", + "economicOverview", +] + +DEFAULT_CONFIG = { + "disabled_card_ids": [], + "row_order": VALID_ROW_IDS.copy(), + "enable_drag_reorder": True, +} + + +def get_default_dashboard_config(): + return deepcopy(DEFAULT_CONFIG) diff --git a/Modules/Backend/dashboard/migrations/0001_initial.py b/Modules/Backend/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..29a2f15 --- /dev/null +++ b/Modules/Backend/dashboard/migrations/0001_initial.py @@ -0,0 +1,36 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FarmDashboardConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("disabled_card_ids", models.JSONField(blank=True, default=list)), + ("row_order", models.JSONField(default=list)), + ("enable_drag_reorder", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="dashboard_config", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_dashboard_configs", + "ordering": ["-updated_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py b/Modules/Backend/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py new file mode 100644 index 0000000..5db19b8 --- /dev/null +++ b/Modules/Backend/dashboard/migrations/0002_alter_farmdashboardconfig_row_order.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.15 on 2026-04-25 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='farmdashboardconfig', + name='row_order', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/Modules/Backend/dashboard/migrations/__init__.py b/Modules/Backend/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/dashboard/mock_data.py b/Modules/Backend/dashboard/mock_data.py new file mode 100644 index 0000000..3309255 --- /dev/null +++ b/Modules/Backend/dashboard/mock_data.py @@ -0,0 +1,21 @@ +""" +Backward-compatible mock exports for dashboard fake content. + +Use `dashboard.defaults` for runtime configuration defaults and +`dashboard.templates` for fallback card payload templates. +""" + +from .defaults import DEFAULT_CONFIG, VALID_CARD_IDS, VALID_ROW_IDS +from .templates import ( + ALL_CARD_TEMPLATES as ALL_CARDS, + ECONOMIC_OVERVIEW, + FARM_ALERTS_TIMELINE, + FARM_ALERTS_TRACKER, + FARM_OVERVIEW_KPIS, + FARM_WEATHER_CARD, + HARVEST_PREDICTION_CARD, + RECOMMENDATIONS_LIST, + SENSOR_VALUES_LIST, + WATER_NEED_PREDICTION, + YIELD_PREDICTION_CHART, +) diff --git a/Modules/Backend/dashboard/models.py b/Modules/Backend/dashboard/models.py new file mode 100644 index 0000000..04dcf62 --- /dev/null +++ b/Modules/Backend/dashboard/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from farm_hub.models import FarmHub + + +class FarmDashboardConfig(models.Model): + farm = models.OneToOneField( + FarmHub, + on_delete=models.CASCADE, + related_name="dashboard_config", + ) + disabled_card_ids = models.JSONField(default=list, blank=True) + row_order = models.JSONField(default=list, blank=True) + enable_drag_reorder = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_dashboard_configs" + ordering = ["-updated_at", "-id"] + + def __str__(self): + return f"Dashboard config for {self.farm.name}" diff --git a/Modules/Backend/dashboard/postman/farm_dashboard.json b/Modules/Backend/dashboard/postman/farm_dashboard.json new file mode 100644 index 0000000..8f171c2 --- /dev/null +++ b/Modules/Backend/dashboard/postman/farm_dashboard.json @@ -0,0 +1 @@ +{"info":{"name":"Farm Dashboard","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Farm Dashboard API. GET/PATCH config (disabled_card_ids, row_order, enable_drag_reorder). GET all cards. Static mock data only. No database."},"item":[{"name":"Get config","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard-config/?farm_uuid={{farmUuid}}","description":"Get dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (disable card)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"disabled_card_ids\": [\n \"farmWeatherCard\",\n \"sensorRadarChart\"\n ],\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (row order)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"row_order\": [\n \"overviewKpis\",\n \"weatherAlerts\",\n \"sensorMonitoring\",\n \"predictions\",\n \"sensorCharts\",\n \"alertsWater\",\n \"soilHeatmap\",\n \"ndviRecommendations\",\n \"economic\"\n ],\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Patch config (enable drag reorder)","request":{"method":"PATCH","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"enable_drag_reorder\": false,\n \"farm_uuid\": \"{{farmUuid}}\"\n}"},"url":"{{baseUrl}}/api/farm-dashboard-config/","description":"PATCH dashboard config for a specific farm."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"disabled_card_ids\": [\"farmWeatherCard\", \"sensorRadarChart\"],\n \"row_order\": [\"overviewKpis\", \"weatherAlerts\", \"sensorMonitoring\", \"sensorCharts\", \"alertsWater\", \"predictions\", \"soilHeatmap\", \"ndviRecommendations\", \"economic\"],\n \"enable_drag_reorder\": true\n }\n}"}]},{"name":"Get all cards","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/?farm_uuid={{farmUuid}}","description":"Get unified response with all 15 card payloads."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]},{"name":"Get all cards (cards path)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/farm-dashboard/cards/?farm_uuid={{farmUuid}}","description":"Get unified response with all 15 card payloads. Same as base path."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"code\": 200,\n \"msg\": \"OK\",\n \"data\": {\n \"farmOverviewKpis\": {},\n \"farmWeatherCard\": {},\n \"farmAlertsTracker\": {},\n \"sensorValuesList\": {},\n \"sensorRadarChart\": {},\n \"sensorComparisonChart\": {},\n \"anomalyDetectionCard\": {},\n \"farmAlertsTimeline\": {},\n \"waterNeedPrediction\": {},\n \"harvestPredictionCard\": {},\n \"yieldPredictionChart\": {},\n \"soilMoistureHeatmap\": {},\n \"ndviHealthCard\": {},\n \"recommendationsList\": {},\n \"economicOverview\": {}\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"},{"key":"farmUuid","value":"550e8400-e29b-41d4-a716-446655440000"}]} \ No newline at end of file diff --git a/Modules/Backend/dashboard/serializers.py b/Modules/Backend/dashboard/serializers.py new file mode 100644 index 0000000..9d62f68 --- /dev/null +++ b/Modules/Backend/dashboard/serializers.py @@ -0,0 +1,61 @@ +from rest_framework import serializers + +from .defaults import VALID_CARD_IDS, VALID_ROW_IDS + + +class FarmDashboardConfigSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(read_only=True) + disabled_card_ids = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + ) + row_order = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + ) + enable_drag_reorder = serializers.BooleanField() + + def validate_disabled_card_ids(self, value): + invalid_ids = [card_id for card_id in value if card_id not in VALID_CARD_IDS] + if invalid_ids: + raise serializers.ValidationError( + f"Invalid card IDs: {', '.join(invalid_ids)}." + ) + if len(set(value)) != len(value): + raise serializers.ValidationError("disabled_card_ids must not contain duplicates.") + return value + + def validate_row_order(self, value): + invalid_ids = [row_id for row_id in value if row_id not in VALID_ROW_IDS] + if invalid_ids: + raise serializers.ValidationError( + f"Invalid row IDs: {', '.join(invalid_ids)}." + ) + if len(set(value)) != len(value): + raise serializers.ValidationError("row_order must not contain duplicates.") + if set(value) != set(VALID_ROW_IDS): + raise serializers.ValidationError( + "row_order must contain each valid row ID exactly once." + ) + return value + + +class FarmDashboardConfigPatchSerializer(FarmDashboardConfigSerializer): + farm_uuid = serializers.UUIDField(required=True) + disabled_card_ids = serializers.ListField( + child=serializers.CharField(), + allow_empty=True, + required=False, + ) + row_order = serializers.ListField( + child=serializers.CharField(), + allow_empty=False, + required=False, + ) + enable_drag_reorder = serializers.BooleanField(required=False) + + def validate(self, attrs): + attrs = super().validate(attrs) + if set(attrs.keys()) == {"farm_uuid"}: + raise serializers.ValidationError("At least one config field must be provided.") + return attrs diff --git a/Modules/Backend/dashboard/services.py b/Modules/Backend/dashboard/services.py new file mode 100644 index 0000000..6e35277 --- /dev/null +++ b/Modules/Backend/dashboard/services.py @@ -0,0 +1,185 @@ +from copy import deepcopy + +from water.services import ( + get_farm_weather_card_data, + get_water_need_prediction_data, + get_water_stress_index_data, +) +from crop_health.services import get_crop_health_summary_data +from economic_overview.services import get_economic_overview_data +from farm_alerts.services import ( + get_alert_timeline_data, + get_alert_tracker_data, + get_recommendations_list_data, +) +from fertilization.services import get_fertilization_dashboard_recommendation +from irrigation.services import get_irrigation_dashboard_recommendation +from pest_detection.services import get_risk_summary_data +from device_hub.services import ( + get_sensor_7_in_1_summary_data, +) +from yield_harvest.services import get_yield_harvest_summary_data + +from .templates import get_all_card_templates + + +def _update_kpi(card_lookup, card_data): + if not card_data: + return + + card_id = card_data.get("id") + if not card_id or card_id not in card_lookup: + return + + details = card_data.get("details") + clean_data = {key: value for key, value in card_data.items() if key != "details"} + card_lookup[card_id].update(clean_data) + if details is not None: + card_lookup[card_id]["details"] = details + + +def _build_quality_score_card(yield_summary): + quality_card = { + "id": "quality_score", + "title": "امتیاز کیفیت", + "subtitle": "برآورد کیفیت محصول", + "stats": "۵۹", + "avatarColor": "info", + "avatarIcon": "tabler-stars", + "chipText": "متوسط", + "chipColor": "warning", + } + + chart_summary = yield_summary.get("yield_prediction_chart", {}).get("summary", []) + if not isinstance(chart_summary, list): + return quality_card + + for item in chart_summary: + if not isinstance(item, dict): + continue + title = str(item.get("title", "")).strip() + if "کیفیت" not in title: + continue + amount = item.get("amount") + subtitle = item.get("subtitle") + if amount not in (None, ""): + quality_card["stats"] = str(amount) + if subtitle: + quality_card["chipText"] = str(subtitle) + quality_card["chipColor"] = "warning" + return quality_card + + return quality_card + + +def _build_days_until_harvest_card(yield_summary): + harvest_card = yield_summary.get("harvest_prediction_card", {}) + days_until = harvest_card.get("daysUntil") + card = { + "id": "days_until_harvest", + "title": "روز تا برداشت", + "subtitle": "زمان باقیمانده تا پنجره برداشت", + "stats": "۱۳۵", + "avatarColor": "warning", + "avatarIcon": "tabler-calendar-event", + "chipText": "برنامه ریزی", + "chipColor": "success", + } + if days_until is not None: + card["stats"] = str(days_until) + return card + + +def _build_overview_kpis(base_cards, crop_health_summary, water_stress_index, avg_soil_moisture, risk_summary, yield_summary): + kpis = [crop_health_summary["farmHealthScore"], water_stress_index, avg_soil_moisture, *deepcopy(base_cards["kpis"])] + card_lookup = {item["id"]: item for item in kpis} + + _update_kpi(card_lookup, water_stress_index) + _update_kpi(card_lookup, avg_soil_moisture) + _update_kpi(card_lookup, risk_summary.get("disease_risk", {})) + _update_kpi(card_lookup, risk_summary.get("pest_risk", {})) + _update_kpi(card_lookup, yield_summary.get("yield_prediction_card", {})) + _update_kpi(card_lookup, _build_quality_score_card(yield_summary)) + _update_kpi(card_lookup, _build_days_until_harvest_card(yield_summary)) + + return {"kpis": kpis} + + +def _build_recommendations_list(farm, fallback_data, harvest_card): + recommendations = [] + recommendations.extend(get_recommendations_list_data(farm).get("recommendations", [])) + recommendations.append(get_irrigation_dashboard_recommendation(farm)) + recommendations.append(get_fertilization_dashboard_recommendation(farm)) + + if harvest_card: + recommendations.append( + { + "title": f"بازه برداشت: {harvest_card.get('optimalWindowStart', '')} تا {harvest_card.get('optimalWindowEnd', '')}", + "subtitle": harvest_card.get("description", ""), + "avatarIcon": "tabler-calendar-event", + "avatarColor": "info", + } + ) + + deduped = [] + seen_titles = set() + for item in recommendations: + title = item.get("title") + if not title or title in seen_titles: + continue + seen_titles.add(title) + deduped.append(item) + + if deduped: + return {"recommendations": deduped[:4]} + + return deepcopy(fallback_data) + + +def get_farm_dashboard_cards(farm): + cards = get_all_card_templates() + + water_cards = { + "farmWeatherCard": get_farm_weather_card_data(farm), + "waterNeedPrediction": get_water_need_prediction_data(farm), + "waterStressIndex": get_water_stress_index_data(farm), + } + crop_health_summary = get_crop_health_summary_data(farm) + risk_summary = get_risk_summary_data(farm) + yield_summary = get_yield_harvest_summary_data(farm) + sensor_summary = get_sensor_7_in_1_summary_data(farm) + alert_cards = { + "farmAlertsTracker": get_alert_tracker_data(farm), + "farmAlertsTimeline": get_alert_timeline_data(farm), + } + economic_overview = get_economic_overview_data(farm) + avg_soil_moisture = sensor_summary["avgSoilMoisture"] + + cards["farmWeatherCard"] = water_cards["farmWeatherCard"] + cards["farmAlertsTracker"] = alert_cards["farmAlertsTracker"] + cards["farmAlertsTimeline"] = alert_cards["farmAlertsTimeline"] + cards["sensorValuesList"] = sensor_summary["sensorValuesList"] + cards["anomalyDetectionCard"] = sensor_summary["anomalyDetectionCard"] + cards["waterNeedPrediction"] = water_cards["waterNeedPrediction"] + cards["harvestPredictionCard"] = yield_summary["harvest_prediction_card"] + cards["yieldPredictionChart"] = yield_summary["yield_prediction_chart"] + cards["sensorRadarChart"] = sensor_summary["sensorRadarChart"] + cards["sensorComparisonChart"] = sensor_summary["sensorComparisonChart"] + cards["soilMoistureHeatmap"] = sensor_summary["soilMoistureHeatmap"] + cards["ndviHealthCard"] = crop_health_summary["ndviHealthCard"] + cards["economicOverview"] = economic_overview + cards["farmOverviewKpis"] = _build_overview_kpis( + cards["farmOverviewKpis"], + crop_health_summary, + water_cards["waterStressIndex"], + avg_soil_moisture, + risk_summary, + yield_summary, + ) + cards["recommendationsList"] = _build_recommendations_list( + farm, + cards["recommendationsList"], + yield_summary.get("harvest_prediction_card", {}), + ) + + return cards diff --git a/Modules/Backend/dashboard/templates.py b/Modules/Backend/dashboard/templates.py new file mode 100644 index 0000000..0fcd02e --- /dev/null +++ b/Modules/Backend/dashboard/templates.py @@ -0,0 +1,318 @@ +""" +Static dashboard payload templates used only as fallback content. +""" + +from copy import deepcopy + + +FARM_OVERVIEW_KPIS = { + "kpis": [ + { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "پیش بینی هوشمند", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-biohazard", + "chipText": "32%", + "chipColor": "success", + }, + { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش بینی هوشمند", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-bug", + "chipText": "22%", + "chipColor": "success", + }, + { + "id": "yield_prediction", + "title": "عملکرد پیش بینی شده", + "subtitle": "پیش بینی عملکرد این مزرعه", + "stats": "۰ تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-arcs", + "chipText": "نیازمند بررسی", + "chipColor": "warning", + }, + { + "id": "quality_score", + "title": "امتیاز کیفیت", + "subtitle": "برآورد کیفیت محصول", + "stats": "۵۹", + "avatarColor": "info", + "avatarIcon": "tabler-stars", + "chipText": "متوسط", + "chipColor": "warning", + }, + { + "id": "days_until_harvest", + "title": "روز تا برداشت", + "subtitle": "زمان باقیمانده تا پنجره برداشت", + "stats": "۱۳۵", + "avatarColor": "warning", + "avatarIcon": "tabler-calendar-event", + "chipText": "برنامه ریزی", + "chipColor": "warning", + }, + ] +} + +FARM_WEATHER_CARD = { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"], + "series": [[18, 22, 26, 28, 25, 20, 18]], + }, +} + +FARM_ALERTS_TRACKER = { + "totalAlerts": 3, + "radialBarValue": 30, + "alertStats": [ + { + "title": "کمبود آب", + "count": "2", + "avatarColor": "error", + "avatarIcon": "tabler-droplet-half-2", + }, + { + "title": "ریسک قارچی", + "count": "1", + "avatarColor": "warning", + "avatarIcon": "tabler-mushroom", + }, + { + "title": "هشدار یخبندان", + "count": "0", + "avatarColor": "info", + "avatarIcon": "tabler-snowflake", + }, + ], +} + +SENSOR_VALUES_LIST = { + "sensors": [ + { + "title": "28°C", + "subtitle": "دمای هوا", + "trendNumber": 2.1, + "trend": "positive", + "unit": "°C", + }, + { + "title": "24°C", + "subtitle": "دمای خاک", + "trendNumber": -0.5, + "trend": "negative", + "unit": "°C", + }, + { + "title": "65%", + "subtitle": "رطوبت هوا", + "trendNumber": 3.2, + "trend": "positive", + "unit": "%", + }, + { + "title": "42%", + "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)", + "trendNumber": -1.8, + "trend": "negative", + "unit": "%", + }, + { + "title": "6.8", + "subtitle": "pH خاک", + "trendNumber": 0.2, + "trend": "positive", + "unit": "pH", + }, + { + "title": "1.2", + "subtitle": "هدایت الکتریکی (dS/m)", + "trendNumber": 0.1, + "trend": "positive", + "unit": "dS/m", + }, + { + "title": "850", + "subtitle": "شدت نور (لوکس)", + "trendNumber": 15.3, + "trend": "positive", + "unit": "lux", + }, + { + "title": "12", + "subtitle": "سرعت باد (کیلومتر/ساعت)", + "trendNumber": -2.4, + "trend": "negative", + "unit": "km/h", + }, + ] +} + +FARM_ALERTS_TIMELINE = { + "alerts": [ + { + "title": "ریسک کمبود آب", + "description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.", + "time": "۱۵ دقیقه پیش", + "color": "warning", + }, + { + "title": "ریسک بیماری قارچی", + "description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.", + "time": "۱ ساعت پیش", + "color": "error", + }, + { + "title": "پیشنهاد آبیاری", + "description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.", + "time": "۲ ساعت پیش", + "color": "info", + }, + { + "title": "بررسی شوری خاک", + "description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.", + "time": "۴ ساعت پیش", + "color": "success", + }, + ] +} + +WATER_NEED_PREDICTION = { + "totalNext7Days": 3290, + "unit": "m³", + "categories": ["روز ۱", "روز ۲", "روز ۳", "روز ۴", "روز ۵", "روز ۶", "روز ۷"], + "series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}], +} + +HARVEST_PREDICTION_CARD = { + "date": "2025-10-15", + "dateFormatted": "۱۵ اکتبر ۲۰۲۵", + "daysUntil": 58, + "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", + "optimalWindowStart": "2025-10-12", + "optimalWindowEnd": "2025-10-18", +} + +YIELD_PREDICTION_CHART = { + "categories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + "series": [ + {"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]}, + {"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]}, + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": "42 تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar", + }, + { + "title": "تاریخ برداشت", + "subtitle": "حدود ۱۵ اکتبر", + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar", + }, + ], +} + +RECOMMENDATIONS_LIST = { + "recommendations": [ + { + "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح", + "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "کود: NPK 20-20-20", + "subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", + }, + { + "title": "قارچ‌کش: پیشگیرانه", + "subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.", + "avatarIcon": "tabler-mushroom", + "avatarColor": "warning", + }, + { + "title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر", + "subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.", + "avatarIcon": "tabler-calendar-event", + "avatarColor": "info", + }, + ] +} + +ECONOMIC_OVERVIEW = { + "economicData": [ + { + "title": "هزینه آب", + "value": "€720", + "subtitle": "این ماه", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "صرفه‌جویی آب هوشمند", + "value": "€156", + "subtitle": "۱۸٪ صرفه‌جویی شده", + "avatarIcon": "tabler-bulb", + "avatarColor": "success", + }, + { + "title": "بازده سرمایه پلتفرم", + "value": "127%", + "subtitle": "نسبت به سال گذشته", + "avatarIcon": "tabler-chart-line", + "avatarColor": "info", + }, + { + "title": "پیش‌بینی درآمد", + "value": "€42k", + "subtitle": "این فصل", + "avatarIcon": "tabler-currency-euro", + "avatarColor": "success", + }, + ], + "chartSeries": [ + {"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]}, + {"name": "کود", "data": [80, 85, 90, 75, 82, 78]}, + ], + "chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], +} + +ALL_CARD_TEMPLATES = { + "farmOverviewKpis": FARM_OVERVIEW_KPIS, + "farmWeatherCard": FARM_WEATHER_CARD, + "farmAlertsTracker": FARM_ALERTS_TRACKER, + "sensorValuesList": SENSOR_VALUES_LIST, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, + "farmAlertsTimeline": FARM_ALERTS_TIMELINE, + "waterNeedPrediction": WATER_NEED_PREDICTION, + "harvestPredictionCard": HARVEST_PREDICTION_CARD, + "yieldPredictionChart": YIELD_PREDICTION_CHART, + "soilMoistureHeatmap": {}, + "ndviHealthCard": {}, + "recommendationsList": RECOMMENDATIONS_LIST, + "economicOverview": ECONOMIC_OVERVIEW, +} + + +def get_all_card_templates(): + return deepcopy(ALL_CARD_TEMPLATES) diff --git a/Modules/Backend/dashboard/tests.py b/Modules/Backend/dashboard/tests.py new file mode 100644 index 0000000..6669faf --- /dev/null +++ b/Modules/Backend/dashboard/tests.py @@ -0,0 +1,186 @@ +from copy import deepcopy + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from access_control.models import AccessFeature, AccessRule +from farm_hub.models import FarmHub, FarmType + +from .defaults import DEFAULT_CONFIG +from .models import FarmDashboardConfig +from .views import FarmDashboardCardsView, FarmDashboardConfigView + + +class DashboardBaseTestCase(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + self.dashboard_feature = AccessFeature.objects.create( + code="greenhouse-dashboard", + name="Greenhouse Dashboard", + feature_type=AccessFeature.PAGE, + ) + self.allow_dashboard_rule = AccessRule.objects.create( + code="allow-greenhouse-dashboard", + name="Allow Greenhouse Dashboard", + priority=10, + ) + self.allow_dashboard_rule.features.add(self.dashboard_feature) + + +class FarmDashboardConfigViewTests(DashboardBaseTestCase): + def test_get_returns_default_config_and_persists_it(self): + request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "OK") + self.assertEqual(response.data["data"], expected) + self.assertTrue(FarmDashboardConfig.objects.filter(farm=self.farm).exists()) + + def test_get_requires_farm_uuid(self): + request = self.factory.get("/api/farm-dashboard-config/") + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "This field is required.") + + def test_get_rejects_foreign_farm_uuid(self): + request = self.factory.get(f"/api/farm-dashboard-config/?farm_uuid={self.other_farm.farm_uuid}") + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "Farm not found.") + + def test_patch_partial_update_returns_full_final_config(self): + request = self.factory.patch( + "/api/farm-dashboard-config/", + { + "farm_uuid": str(self.farm.farm_uuid), + "disabled_card_ids": ["farmWeatherCard"], + }, + format="json", + ) + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) + expected["disabled_card_ids"] = ["farmWeatherCard"] + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"], expected) + self.assertEqual( + FarmDashboardConfig.objects.get(farm=self.farm).disabled_card_ids, + ["farmWeatherCard"], + ) + + def test_patch_only_drag_flag_still_returns_full_config(self): + request = self.factory.patch( + "/api/farm-dashboard-config/", + { + "farm_uuid": str(self.farm.farm_uuid), + "enable_drag_reorder": False, + }, + format="json", + ) + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + expected = deepcopy(DEFAULT_CONFIG) + expected["farm_uuid"] = str(self.farm.farm_uuid) + expected["enable_drag_reorder"] = False + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"], expected) + self.assertIn("disabled_card_ids", response.data["data"]) + self.assertIn("row_order", response.data["data"]) + + def test_patch_rejects_invalid_row_order(self): + request = self.factory.patch( + "/api/farm-dashboard-config/", + { + "farm_uuid": str(self.farm.farm_uuid), + "row_order": ["overviewKpis"], + }, + format="json", + ) + force_authenticate(request, user=self.user) + response = FarmDashboardConfigView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("row_order", response.data) + + +class FarmDashboardCardsViewTests(DashboardBaseTestCase): + def test_get_returns_locally_aggregated_cards(self): + request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "OK") + self.assertIn("farmWeatherCard", response.data["data"]) + self.assertIn("farmAlertsTracker", response.data["data"]) + self.assertIn("yieldPredictionChart", response.data["data"]) + self.assertIn("ndviHealthCard", response.data["data"]) + self.assertIn("sensorRadarChart", response.data["data"]) + self.assertIn("soilMoistureHeatmap", response.data["data"]) + self.assertIn("economicOverview", response.data["data"]) + self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][0]["id"], "farm_health_score") + self.assertEqual(response.data["data"]["farmOverviewKpis"]["kpis"][2]["id"], "avg_soil_moisture") + kpi_ids = [item["id"] for item in response.data["data"]["farmOverviewKpis"]["kpis"]] + self.assertIn("disease_risk", kpi_ids) + self.assertIn("pest_risk", kpi_ids) + self.assertIn("yield_prediction", kpi_ids) + self.assertIn("quality_score", kpi_ids) + self.assertIn("days_until_harvest", kpi_ids) + + def test_get_requires_farm_uuid(self): + request = self.factory.get("/api/farm-dashboard/") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "This field is required.") + + def test_get_denies_access_when_feature_is_blocked(self): + deny_rule = AccessRule.objects.create( + code="deny-greenhouse-dashboard", + name="Deny Greenhouse Dashboard", + priority=20, + effect=AccessRule.DENY, + ) + deny_rule.features.add(self.dashboard_feature) + + request = self.factory.get(f"/api/farm-dashboard/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmDashboardCardsView.as_view()(request) + + self.assertEqual(response.status_code, 403) diff --git a/Modules/Backend/dashboard/urls.py b/Modules/Backend/dashboard/urls.py new file mode 100644 index 0000000..a466313 --- /dev/null +++ b/Modules/Backend/dashboard/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import FarmDashboardCardsView + +urlpatterns = [ + # path("cards/", FarmDashboardCardsView.as_view(), name="farm-dashboard-cards"), + path("", FarmDashboardCardsView.as_view(), name="farm-dashboard"), +] diff --git a/Modules/Backend/dashboard/urls_config.py b/Modules/Backend/dashboard/urls_config.py new file mode 100644 index 0000000..93450a5 --- /dev/null +++ b/Modules/Backend/dashboard/urls_config.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import FarmDashboardConfigView + +urlpatterns = [ + path("", FarmDashboardConfigView.as_view(), name="farm-dashboard-config"), +] diff --git a/Modules/Backend/dashboard/views.py b/Modules/Backend/dashboard/views.py new file mode 100644 index 0000000..373d818 --- /dev/null +++ b/Modules/Backend/dashboard/views.py @@ -0,0 +1,132 @@ +""" +Farm Dashboard API views. +""" + +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view + +from config.swagger import code_response +from farm_hub.models import FarmHub +from .defaults import get_default_dashboard_config +from .services import get_farm_dashboard_cards +from .models import FarmDashboardConfig +from .serializers import FarmDashboardConfigPatchSerializer, FarmDashboardConfigSerializer + + +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + @staticmethod + def _get_or_create_dashboard_config(farm): + default_config = get_default_dashboard_config() + config, _created = FarmDashboardConfig.objects.get_or_create( + farm=farm, + defaults={ + "disabled_card_ids": default_config["disabled_card_ids"], + "row_order": default_config["row_order"], + "enable_drag_reorder": default_config["enable_drag_reorder"], + }, + ) + return config + + @staticmethod + def _serialize_config(config): + return { + "farm_uuid": str(config.farm.farm_uuid), + "disabled_card_ids": config.disabled_card_ids, + "row_order": config.row_order, + "enable_drag_reorder": config.enable_drag_reorder, + } + + +@extend_schema_view( + get=extend_schema( + tags=["Farm Dashboard"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: code_response("FarmDashboardConfigGetResponse", data=FarmDashboardConfigSerializer())}, + ), + patch=extend_schema( + tags=["Farm Dashboard"], + request=FarmDashboardConfigPatchSerializer, + responses={200: code_response("FarmDashboardConfigPatchResponse", data=FarmDashboardConfigSerializer())}, + ), +) +class FarmDashboardConfigView(FarmAccessMixin, APIView): + """ + Farm dashboard config endpoints. + GET/PATCH are persisted in DB per farm. + """ + + permission_classes = [IsAuthenticated] + required_feature_code = "greenhouse-dashboard" + + def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + config = self._get_or_create_dashboard_config(farm) + return Response( + {"code": 200, "msg": "OK", "data": self._serialize_config(config)}, + status=status.HTTP_200_OK, + ) + + def patch(self, request): + serializer = FarmDashboardConfigPatchSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + config = self._get_or_create_dashboard_config(farm) + + update_fields = ["updated_at"] + if "disabled_card_ids" in serializer.validated_data: + config.disabled_card_ids = serializer.validated_data["disabled_card_ids"] + update_fields.append("disabled_card_ids") + if "row_order" in serializer.validated_data: + config.row_order = serializer.validated_data["row_order"] + update_fields.append("row_order") + if "enable_drag_reorder" in serializer.validated_data: + config.enable_drag_reorder = serializer.validated_data["enable_drag_reorder"] + update_fields.append("enable_drag_reorder") + config.save(update_fields=update_fields) + + return Response( + {"code": 200, "msg": "OK", "data": self._serialize_config(config)}, + status=status.HTTP_200_OK, + ) + + +@extend_schema_view( + get=extend_schema( + tags=["Farm Dashboard"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: code_response("FarmDashboardCardsResponse", data=serializers.JSONField())}, + ), +) +class FarmDashboardCardsView(FarmAccessMixin, APIView): + """ + Farm dashboard cards endpoint: GET. + Requires farm_uuid and assembles local dashboard services. + """ + + permission_classes = [IsAuthenticated] + required_feature_code = "greenhouse-dashboard" + + def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + return Response( + {"code": 200, "msg": "OK", "data": get_farm_dashboard_cards(farm)}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/device_hub/API_GUIDE.md b/Modules/Backend/device_hub/API_GUIDE.md new file mode 100644 index 0000000..4a9f3b3 --- /dev/null +++ b/Modules/Backend/device_hub/API_GUIDE.md @@ -0,0 +1,734 @@ +# Device Hub API Guide + +این فایل مستند API های تعریف‌شده در `device_hub/urls.py` را توضیح می‌دهد. مسیر پایه این API ها طبق `config/urls.py` برابر است با: + +- `api/device-hub/` + +بیشتر endpointها نیاز به احراز هویت کاربر دارند و معمولاً با ساختار زیر پاسخ می‌دهند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +## نحوه ارتباط با API ها + +### 1) احراز هویت + +- برای بیشتر endpointها باید کاربر لاگین باشد. +- معمولاً توکن را در هدر `Authorization` ارسال می‌کنید. +- نمونه: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +### 2) آدرس پایه + +اگر دامنه پروژه مثلاً `https://example.com` باشد، آدرس کامل endpointها به این صورت است: + +- `https://example.com/api/device-hub/catalog/` +- `https://example.com/api/device-hub/devices//latest/?device_code=` + +### 3) پارامترهای مهم + +- `physical_device_uuid`: شناسه فیزیکی دستگاه +- `device_code`: کد نوع device داخل catalog +- `range`: بازه زمانی (`1h`, `24h`, `7d`, `30d`, `today` بسته به endpoint) +- `page` و `page_size`: برای صفحه‌بندی لاگ‌ها + +--- + +## 1. دریافت لیست کاتالوگ دستگاه‌ها + +### Endpoint + +- `GET /api/device-hub/catalog/` +- `GET /api/device-hub/` + +### کاربرد + +برای گرفتن لیست همه device catalogها استفاده می‌شود. + +### درخواست نمونه + +```bash +curl -X GET "https://example.com/api/device-hub/catalog/" \ + -H "Authorization: Bearer " +``` + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "code": "soil_sensor_v2", + "name": "Soil Sensor V2", + "description": "", + "device_communication_type": "output_only", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": ["soil_moisture", "soil_temperature"], + "payload_mapping": { + "soil_moisture": ["moisture", "soil_moisture"], + "soil_temperature": ["temperature", "soil_temperature"] + }, + "display_schema": {}, + "supported_widgets": ["values_list", "comparison_chart", "radar_chart"], + "commands_schema": [], + "capabilities": [], + "sample_payload": {}, + "is_active": true + } + ] +} +``` + +### فیلدهای مهم پاسخ + +- `device_communication_type`: نوع ارتباط دستگاه (`output_only` یا `input_only`) +- `returned_data_fields`: داده‌هایی که device برمی‌گرداند +- `payload_mapping`: نگاشت کلیدهای payload خام به فیلدهای نرمال +- `supported_widgets`: ویجت‌هایی که فرانت می‌تواند نمایش دهد +- `commands_schema`: لیست commandهای قابل ارسال برای deviceهای commandable + +--- + +## 2. جزئیات یک دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//?device_code=` + +### کاربرد + +اطلاعات کلی دستگاه ثبت‌شده در فارم را برمی‌گرداند. + +### Query Params + +- `device_code` اجباری + +### درخواست نمونه + +```bash +curl -X GET "https://example.com/api/device-hub/devices/22222222-2222-2222-2222-222222222222/?device_code=soil_sensor_v2" \ + -H "Authorization: Bearer " +``` + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "uuid": "33333333-3333-3333-3333-333333333333", + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "name": "Soil Device 1", + "sensor_type": "soil", + "is_active": true, + "specifications": {}, + "power_source": {}, + "device_catalog": { + "uuid": "11111111-1111-1111-1111-111111111111", + "code": "soil_sensor_v2", + "name": "Soil Sensor V2", + "description": "", + "device_communication_type": "output_only", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": ["soil_moisture", "soil_temperature"], + "payload_mapping": {}, + "display_schema": {}, + "supported_widgets": ["values_list", "comparison_chart", "radar_chart"], + "commands_schema": [], + "capabilities": [], + "sample_payload": {}, + "is_active": true + }, + "device_catalogs": [], + "last_payload_at": "2025-01-01T10:00:00Z", + "created_at": "2025-01-01T09:00:00Z", + "updated_at": "2025-01-01T09:00:00Z" + } +} +``` + +### نکته + +- اگر `physical_device_uuid` متعلق به کاربر نباشد، خطای 400 با متن `Device not found.` برمی‌گردد. +- اگر `device_code` به این دستگاه attach نشده باشد، خطای validation دریافت می‌کنید. + +--- + +## 3. آخرین payload دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//latest/?device_code=` + +### کاربرد + +آخرین payload خام و نرمال‌شده دستگاه را می‌دهد. + +### Query Params + +- `device_code` اجباری + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "device_code": "soil_sensor_v2", + "device_catalog_code": "soil_sensor_v2", + "raw_payload": { + "moisture": 52.4, + "temperature": 23.1 + }, + "normalized_payload": { + "soil_moisture": 52.4, + "soil_temperature": 23.1 + }, + "readings": { + "soil_moisture": 52.4, + "soil_temperature": 23.1 + }, + "created_at": "2025-01-01T10:00:00Z" + } +} +``` + +### معنی فیلدها + +- `raw_payload`: داده خامی که از لاگ ذخیره‌شده آمده +- `normalized_payload`: داده تبدیل‌شده بر اساس `payload_mapping` +- `readings`: مقادیر قابل نمایش برای UI + +--- + +## 4. خلاصه دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//summary/?device_code=` + +### کاربرد + +یک summary مناسب UI برمی‌گرداند؛ مثلاً ویجت‌های قابل نمایش، values list، chartها و commandها. + +### Query Params + +- `device_code` اجباری + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "sensor": { + "name": "Soil Device 1", + "physicalDeviceUuid": "22222222-2222-2222-2222-222222222222", + "sensorCatalogCode": "soil_sensor_v2", + "updatedAt": "2025-01-01T10:00:00Z" + }, + "supportedWidgets": ["values_list", "comparison_chart", "radar_chart"], + "sensorValuesList": { + "sensor": { + "name": "Soil Device 1", + "physicalDeviceUuid": "22222222-2222-2222-2222-222222222222", + "sensorCatalogCode": "soil_sensor_v2", + "updatedAt": "2025-01-01T10:00:00Z" + }, + "sensors": [ + { + "id": "soil_moisture", + "title": "رطوبت خاک", + "subtitle": "45-65%", + "trendNumber": 52.4, + "trend": "normal", + "unit": "%" + } + ] + }, + "commands": [] + } +} +``` + +### نکته + +- شکل دقیق `data` بسته به `supported_widgets` و نوع catalog تغییر می‌کند. +- اگر history وجود نداشته باشد، معمولاً خطای 400 برمی‌گردد. + +--- + +## 5. نمودار مقایسه‌ای دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//comparison-chart/?device_code=&range=7d` + +### Query Params + +- `device_code` اجباری +- `range` اختیاری: `7d` یا `30d` + +### پاسخ نمونه + +```json +{ + "series": [ + { + "name": "Moisture", + "data": [50.0, 51.2, 52.4] + }, + { + "name": "Temperature", + "data": [22.0, 22.4, 23.1] + } + ], + "categories": ["شنبه", "یکشنبه", "دوشنبه"], + "currentValue": 52.4, + "vsLastWeek": "+3.1%" +} +``` + +### نکته + +- این endpoint برخلاف بعضی endpointهای دیگر wrapper `code/msg` ندارد و مستقیم data chart را برمی‌گرداند. + +--- + +## 6. لیست مقادیر دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//values-list/?device_code=&range=7d` + +### Query Params + +- `device_code` اجباری +- `range` اختیاری: `1h`، `24h`، `7d` + +### پاسخ نمونه + +```json +{ + "sensors": [ + { + "title": "Moisture", + "subtitle": "45-65%", + "trendNumber": 52.4, + "trend": "positive", + "unit": "%" + }, + { + "title": "Temperature", + "subtitle": "18-28°C", + "trendNumber": 23.1, + "trend": "positive", + "unit": "°C" + } + ] +} +``` + +### نکته + +- این endpoint هم مستقیم JSON داده را برمی‌گرداند و wrapper `code/msg` ندارد. + +--- + +## 7. رادار چارت دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//radar-chart/?device_code=&range=7d` + +### Query Params + +- `device_code` اجباری +- `range` اختیاری: `today`، `7d`، `30d` + +### پاسخ نمونه + +```json +{ + "labels": ["Moisture", "Temperature", "PH", "EC"], + "series": [ + { + "name": "Current", + "data": [52.4, 23.1, 6.7, 1.1] + } + ] +} +``` + +### نکته + +- این endpoint هم مستقیم data را برمی‌گرداند. + +--- + +## 8. لاگ‌های دستگاه + +### Endpoint + +- `GET /api/device-hub/devices//logs/?device_code=&page=1&page_size=20` + +### Query Params + +- `device_code` اجباری +- `page` اختیاری، پیش‌فرض `1` +- `page_size` اختیاری، پیش‌فرض `20`، حداکثر `100` + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "count": 1, + "next": null, + "previous": null, + "data": [ + { + "id": 10, + "farm_uuid": "44444444-4444-4444-4444-444444444444", + "sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111", + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "farm_device": { + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111", + "device_catalogs": [], + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "name": "Soil Device 1", + "sensor_type": "soil", + "is_active": true, + "specifications": {}, + "power_source": {}, + "created_at": "2025-01-01T09:00:00Z", + "updated_at": "2025-01-01T09:00:00Z" + }, + "sensor_catalog": { + "uuid": "11111111-1111-1111-1111-111111111111", + "code": "soil_sensor_v2", + "name": "Soil Sensor V2", + "description": "", + "device_communication_type": "output_only", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": [], + "payload_mapping": {}, + "display_schema": {}, + "supported_widgets": [], + "commands_schema": [], + "capabilities": [], + "sample_payload": {}, + "is_active": true, + "created_at": "2025-01-01T09:00:00Z", + "updated_at": "2025-01-01T09:00:00Z" + }, + "payload": { + "moisture": 52.4, + "temperature": 23.1 + }, + "created_at": "2025-01-01T10:00:00Z" + } + ] +} +``` + +### کاربرد + +- برای history دستگاه +- برای نمایش payloadهای دریافت‌شده از device +- برای debug یا audit + +--- + +## 9. ارسال command به دستگاه + +### Endpoint + +- `POST /api/device-hub/devices//commands/` + +### کاربرد + +برای deviceهایی که `input_only` یا commandable هستند، command ارسال می‌کند. + +### Body + +```json +{ + "device_code": "valve_v1", + "command": "open", + "payload": { + "duration_seconds": 120 + } +} +``` + +### درخواست نمونه + +```bash +curl -X POST "https://example.com/api/device-hub/devices/22222222-2222-2222-2222-222222222222/commands/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "device_code": "valve_v1", + "command": "open", + "payload": { + "duration_seconds": 120 + } + }' +``` + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "command accepted", + "data": { + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "command": "open", + "status": "accepted" + } +} +``` + +### نکات + +- `device_code` باید جزو catalogهای متصل به آن device باشد. +- اگر command یا device code معتبر نباشد، خطای 400 برمی‌گردد. + +--- + +## 10. خلاصه سنسور 7in1 + +### Endpoint + +- `GET /api/device-hub/summary/?farm_uuid=` + +### کاربرد + +خلاصه‌ای از سنسور اصلی مزرعه برای UI برمی‌گرداند. + +### Query Params + +- `farm_uuid` اجباری + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "sensor": { + "name": "Main Soil Sensor", + "physicalDeviceUuid": "22222222-2222-2222-2222-222222222222", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-01T10:00:00Z" + }, + "sensorValuesList": {}, + "avgSoilMoisture": {}, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, + "soilMoistureHeatmap": {} + } +} +``` + +--- + +## 11. ثبت payload خارجی دستگاه + +### Endpoint + +- `POST /api/device-hub/external/` + +### کاربرد + +این endpoint برای سیستم یا دستگاه خارجی است تا payload را به backend ارسال کند. +این endpoint با API key اختصاصی کار می‌کند، نه با توکن کاربر. + +### Header + +```http +X-API-Key: +Content-Type: application/json +``` + +### Body + +```json +{ + "uuid": "22222222-2222-2222-2222-222222222222", + "payload": { + "moisture_percent": 32.5, + "temperature_c": 21.3, + "ph": 6.7, + "ec_ds_m": 1.1, + "nitrogen_mg_kg": 42, + "phosphorus_mg_kg": 18, + "potassium_mg_kg": 210 + } +} +``` + +### رفتار endpoint + +- payload را در `SensorExternalRequestLog` ذخیره می‌کند +- notification می‌سازد +- payload را به farm-data service فوروارد می‌کند + +### پاسخ موفق نمونه + +```json +{ + "code": 201, + "msg": "success", + "data": { + "id": 1, + "title": "Sensor external API request" + } +} +``` + +### خطاهای مهم + +- `401`: اگر `X-API-Key` اشتباه یا خالی باشد +- `404`: اگر `physical device` پیدا نشود +- `503`: اگر migrationها آماده نباشند یا سرویس farm-data در دسترس نباشد + +--- + +## 12. لیست لاگ‌های ورودی خارجی + +### Endpoint + +- `GET /api/device-hub/external/logs/?farm_uuid=&page=1&page_size=20` + +### Query Params + +- `farm_uuid` اجباری +- `page` اجباری در داکیومنت، ولی در عمل اگر نفرستید پیش‌فرض `1` دارد در paginator +- `page_size` اجباری در داکیومنت +- `physical_device_uuid` اختیاری +- `sensor_type` اختیاری +- `date_from` اختیاری +- `date_to` اختیاری + +### پاسخ نمونه + +```json +{ + "code": 200, + "msg": "success", + "count": 25, + "next": "https://example.com/api/device-hub/external/logs/?page=2", + "previous": null, + "data": [ + { + "id": 10, + "farm_uuid": "44444444-4444-4444-4444-444444444444", + "sensor_catalog_uuid": "11111111-1111-1111-1111-111111111111", + "physical_device_uuid": "22222222-2222-2222-2222-222222222222", + "farm_device": null, + "sensor_catalog": null, + "payload": { + "moisture": 52.4 + }, + "created_at": "2025-01-01T10:00:00Z" + } + ] +} +``` + +--- + +## الگوی خطاها + +### Validation Error + +در بیشتر خطاهای اعتبارسنجی، پاسخ شبیه این است: + +```json +{ + "device_code": [ + "Device code is not attached to this farm device." + ] +} +``` +یا: + +```json +{ + "physical_device_uuid": [ + "Device not found." + ] +} +``` + +### Unauthorized + +اگر توکن کاربر یا API key درست نباشد، پاسخ 401 دریافت می‌کنید. + +### Service Unavailable + +در endpointهای `external` اگر migration یا سرویس وابسته آماده نباشد: + +```json +{ + "code": 503, + "msg": "Required tables are not ready. Run migrations." +} +``` + +--- + +## ترتیب پیشنهادی استفاده در فرانت + +برای صفحه جزئیات device معمولاً این ترتیب مناسب است: + +1. گرفتن catalogها از `GET /api/device-hub/catalog/` +2. گرفتن جزئیات device از `GET /api/device-hub/devices//?device_code=...` +3. گرفتن summary از `GET /api/device-hub/devices//summary/?device_code=...` +4. در صورت نیاز گرفتن: + - latest payload + - comparison chart + - radar chart + - values list + - logs + +برای deviceهای commandable: + +1. از `commands_schema` در catalog commandهای مجاز را بخوانید +2. سپس به `POST /api/device-hub/devices//commands/` درخواست بزنید + +--- + +## محل فایل‌های مرتبط در کد + +- مسیرها: `device_hub/urls.py` +- ویوها: `device_hub/views.py` +- serializerها: `device_hub/serializers.py` +- serializerهای summary/chart: `device_hub/sensor_serializers.py` +- مسیر پایه پروژه: `config/urls.py` diff --git a/Modules/Backend/device_hub/__init__.py b/Modules/Backend/device_hub/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/device_hub/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/device_hub/apps.py b/Modules/Backend/device_hub/apps.py new file mode 100644 index 0000000..9243a32 --- /dev/null +++ b/Modules/Backend/device_hub/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class DeviceHubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "device_hub" + verbose_name = "Device Hub" + diff --git a/Modules/Backend/device_hub/authentication.py b/Modules/Backend/device_hub/authentication.py new file mode 100644 index 0000000..d0b7489 --- /dev/null +++ b/Modules/Backend/device_hub/authentication.py @@ -0,0 +1,18 @@ +from django.conf import settings +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +class SensorExternalAPIKeyAuthentication(BaseAuthentication): + keyword = "Api-Key" + + def authenticate(self, request): + provided_key = request.headers.get("X-API-Key") or request.headers.get("Authorization") + expected_key = getattr(settings, "SENSOR_EXTERNAL_API_KEY", "12345") + if not provided_key: + raise AuthenticationFailed("API key is required.") + if provided_key.startswith(f"{self.keyword} "): + provided_key = provided_key[len(self.keyword) + 1 :] + if provided_key != expected_key: + raise AuthenticationFailed("Invalid API key.") + return (None, None) diff --git a/Modules/Backend/device_hub/catalog_seed.py b/Modules/Backend/device_hub/catalog_seed.py new file mode 100644 index 0000000..7015ec7 --- /dev/null +++ b/Modules/Backend/device_hub/catalog_seed.py @@ -0,0 +1,52 @@ +from .models import DeviceCatalog + + +SENSOR_CATALOG_ITEMS = [ + { + "code": "sensor_7_soil_moisture_sensor_v1_2", + "name": "Sensor 7 - Soil Moisture Sensor v1.2", + "description": ( + "This sensor is typically the YL-69 or FC-28 soil moisture sensor. " + "It measures only soil moisture and provides analog and digital outputs. " + "It does not report soil temperature, pH, or nutrients." + ), + "device_communication_type": "output_only", + "customizable_fields": [], + "supported_power_sources": ["solar", "direct_power"], + "returned_data_fields": ["soil_moisture", "analog_output", "digital_output"], + "sample_payload": { + "soil_moisture": 42, + "analog_output": 610, + "digital_output": 1, + }, + "is_active": True, + } +] + + +def seed_sensor_catalog(): + created_count = 0 + updated_count = 0 + results = [] + + for item in SENSOR_CATALOG_ITEMS: + sensor, created = DeviceCatalog.objects.update_or_create( + code=item["code"], + defaults={ + "name": item["name"], + "description": item["description"], + "device_communication_type": item.get("device_communication_type", "output_only"), + "customizable_fields": item["customizable_fields"], + "supported_power_sources": item["supported_power_sources"], + "returned_data_fields": item["returned_data_fields"], + "sample_payload": item["sample_payload"], + "is_active": item["is_active"], + }, + ) + results.append((sensor, created)) + if created: + created_count += 1 + else: + updated_count += 1 + + return results, created_count, updated_count diff --git a/Modules/Backend/device_hub/comparison_urls.py b/Modules/Backend/device_hub/comparison_urls.py new file mode 100644 index 0000000..6e1ee73 --- /dev/null +++ b/Modules/Backend/device_hub/comparison_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import SensorComparisonChartView, SensorRadarChartView, SensorValuesListView + +urlpatterns = [ + path("comparison-chart/", SensorComparisonChartView.as_view(), name="sensor-comparison-chart"), + path("radar-chart/", SensorRadarChartView.as_view(), name="sensor-radar-chart"), + path("values-list/", SensorValuesListView.as_view(), name="sensor-values-list"), +] diff --git a/Modules/Backend/device_hub/management/__init__.py b/Modules/Backend/device_hub/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/device_hub/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/device_hub/management/commands/__init__.py b/Modules/Backend/device_hub/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/device_hub/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/device_hub/management/commands/seed_sensor_7_in_1.py b/Modules/Backend/device_hub/management/commands/seed_sensor_7_in_1.py new file mode 100644 index 0000000..35ec73d --- /dev/null +++ b/Modules/Backend/device_hub/management/commands/seed_sensor_7_in_1.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand, CommandError + +from device_hub.seeds import seed_sensor_7_in_1_demo_data + + +class Command(BaseCommand): + help = "Create or refresh demo sensor 7 in 1 data for summary and chart endpoints." + + def handle(self, *args, **options): + try: + result = seed_sensor_7_in_1_demo_data() + except ValueError as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write( + self.style.SUCCESS( + "Sensor 7 in 1 demo data seeded: " + f"farm_uuid={result['farm'].farm_uuid}, " + f"sensor_catalog={result['sensor_catalog'].code}, " + f"physical_device_uuid={result['sensor'].physical_device_uuid}, " + f"logs={result['log_count']}" + ) + ) diff --git a/Modules/Backend/device_hub/management/commands/seed_sensor_catalog.py b/Modules/Backend/device_hub/management/commands/seed_sensor_catalog.py new file mode 100644 index 0000000..073c35a --- /dev/null +++ b/Modules/Backend/device_hub/management/commands/seed_sensor_catalog.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand + +from device_hub.catalog_seed import seed_sensor_catalog + + +class Command(BaseCommand): + help = "Seed sensor catalog data." + + def handle(self, *args, **options): + results, created_count, updated_count = seed_sensor_catalog() + for sensor, created in results: + if created: + self.stdout.write(self.style.SUCCESS(f"Created sensor catalog item: {sensor.name}")) + else: + self.stdout.write(self.style.WARNING(f"Updated sensor catalog item: {sensor.name}")) + + self.stdout.write( + self.style.SUCCESS( + f"Sensor catalog seeding complete. Created: {created_count}, Updated: {updated_count}" + ) + ) + diff --git a/Modules/Backend/device_hub/migrations/0001_initial.py b/Modules/Backend/device_hub/migrations/0001_initial.py new file mode 100644 index 0000000..cd39d5e --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0001_initial.py @@ -0,0 +1,108 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +def _create_model_if_missing(app_label, model_name): + def _operation(apps, schema_editor): + model = apps.get_model(app_label, model_name) + existing_tables = set(schema_editor.connection.introspection.table_names()) + if model._meta.db_table in existing_tables: + return + schema_editor.create_model(model) + + return _operation + + +class Migration(migrations.Migration): + initial = True + atomic = False + + dependencies = [ + ("farm_hub", "0001_initial"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name="SensorCatalog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("code", models.CharField(db_index=True, max_length=255, unique=True)), + ("name", models.CharField(db_index=True, max_length=255, unique=True)), + ("description", models.TextField(blank=True, default="")), + ("customizable_fields", models.JSONField(blank=True, default=list)), + ("supported_power_sources", models.JSONField(blank=True, default=list)), + ("returned_data_fields", models.JSONField(blank=True, default=list)), + ("sample_payload", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"db_table": "sensor_catalogs", "ordering": ["code"]}, + ), + ], + ), + migrations.RunPython( + _create_model_if_missing("device_hub", "SensorCatalog"), + migrations.RunPython.noop, + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name="FarmSensor", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(max_length=255)), + ("sensor_type", models.CharField(blank=True, default="", max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("specifications", models.JSONField(blank=True, default=dict)), + ("power_source", models.JSONField(blank=True, default=dict)), + ("customization", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sensors", + to="farm_hub.farmhub", + ), + ), + ], + options={"db_table": "farm_sensors", "ordering": ["-created_at"]}, + ), + ], + ), + migrations.RunPython( + _create_model_if_missing("device_hub", "FarmSensor"), + migrations.RunPython.noop, + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.CreateModel( + name="SensorExternalRequestLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(db_index=True)), + ("sensor_catalog_uuid", models.UUIDField(blank=True, db_index=True, null=True)), + ("physical_device_uuid", models.UUIDField(db_index=True)), + ("payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={"db_table": "sensor_external_request_logs", "ordering": ["-created_at", "-id"]}, + ), + ], + ), + migrations.RunPython( + _create_model_if_missing("device_hub", "SensorExternalRequestLog"), + migrations.RunPython.noop, + ), + ] diff --git a/Modules/Backend/device_hub/migrations/0002_absorb_sensor_7_in_1.py b/Modules/Backend/device_hub/migrations/0002_absorb_sensor_7_in_1.py new file mode 100644 index 0000000..ca14824 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0002_absorb_sensor_7_in_1.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0001_initial"), + ] + + operations = [] diff --git a/Modules/Backend/device_hub/migrations/0003_absorb_sensor_external_api.py b/Modules/Backend/device_hub/migrations/0003_absorb_sensor_external_api.py new file mode 100644 index 0000000..d730edc --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0003_absorb_sensor_external_api.py @@ -0,0 +1,9 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0002_absorb_sensor_7_in_1"), + ] + + operations = [] diff --git a/Modules/Backend/device_hub/migrations/0004_absorb_sensor_catalog.py b/Modules/Backend/device_hub/migrations/0004_absorb_sensor_catalog.py new file mode 100644 index 0000000..e36ea15 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0004_absorb_sensor_catalog.py @@ -0,0 +1,35 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0003_absorb_sensor_external_api"), + ("farm_hub", "0003_farmsensor_catalog_and_physical_device"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name="farmsensor", + name="physical_device_uuid", + field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + migrations.AddField( + model_name="farmsensor", + name="sensor_catalog", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farm_sensors", + to="device_hub.sensorcatalog", + ), + ), + ], + ), + ] diff --git a/Modules/Backend/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py b/Modules/Backend/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py new file mode 100644 index 0000000..3b4f120 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0005_rename_farm_sensor_to_farm_device.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0004_absorb_sensor_catalog"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RenameModel( + old_name="FarmSensor", + new_name="FarmDevice", + ), + ], + ), + ] diff --git a/Modules/Backend/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py b/Modules/Backend/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py new file mode 100644 index 0000000..5bb1380 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type.py @@ -0,0 +1,42 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("device_hub", "0005_rename_farm_sensor_to_farm_device"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RenameModel( + old_name="SensorCatalog", + new_name="DeviceCatalog", + ), + migrations.AddField( + model_name="devicecatalog", + name="device_communication_type", + field=models.CharField( + choices=[("output_only", "Output Only"), ("input_only", "Input Only")], + db_index=True, + default="output_only", + max_length=32, + ), + ), + migrations.AlterField( + model_name="farmdevice", + name="sensor_catalog", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farm_devices", + to="device_hub.devicecatalog", + ), + ), + ], + ), + ] diff --git a/Modules/Backend/device_hub/migrations/0007_devicecatalog_dynamic_fields.py b/Modules/Backend/device_hub/migrations/0007_devicecatalog_dynamic_fields.py new file mode 100644 index 0000000..6e601d2 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0007_devicecatalog_dynamic_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("device_hub", "0006_rename_sensorcatalog_to_devicecatalog_and_add_communication_type"), + ] + + operations = [ + migrations.AddField( + model_name="devicecatalog", + name="capabilities", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="devicecatalog", + name="commands_schema", + field=models.JSONField(blank=True, default=list), + ), + migrations.AddField( + model_name="devicecatalog", + name="display_schema", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="devicecatalog", + name="payload_mapping", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="devicecatalog", + name="supported_widgets", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/Modules/Backend/device_hub/migrations/0008_farmdevice_device_catalogs.py b/Modules/Backend/device_hub/migrations/0008_farmdevice_device_catalogs.py new file mode 100644 index 0000000..3f558aa --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0008_farmdevice_device_catalogs.py @@ -0,0 +1,51 @@ +from django.db import migrations, models + + +def ensure_device_catalogs_m2m_table(apps, schema_editor): + FarmDevice = apps.get_model("device_hub", "FarmDevice") + through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through + existing_tables = set(schema_editor.connection.introspection.table_names()) + if through_model._meta.db_table not in existing_tables: + schema_editor.create_model(through_model) + + +def copy_sensor_catalog_to_device_catalogs(apps, schema_editor): + FarmDevice = apps.get_model("device_hub", "FarmDevice") + through_model = FarmDevice._meta.get_field("device_catalogs").remote_field.through + through_table = through_model._meta.db_table + farm_device_column = through_model._meta.get_field("farmdevice").column + device_catalog_column = through_model._meta.get_field("devicecatalog").column + + with schema_editor.connection.cursor() as cursor: + for farm_device_id, sensor_catalog_id in FarmDevice.objects.exclude(sensor_catalog__isnull=True).values_list("pk", "sensor_catalog_id").iterator(): + cursor.execute( + f""" + INSERT IGNORE INTO {schema_editor.quote_name(through_table)} + ({schema_editor.quote_name(farm_device_column)}, {schema_editor.quote_name(device_catalog_column)}) + VALUES (%s, %s) + """, + [farm_device_id, sensor_catalog_id], + ) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("device_hub", "0007_devicecatalog_dynamic_fields"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name="farmdevice", + name="device_catalogs", + field=models.ManyToManyField(blank=True, related_name="composite_farm_devices", to="device_hub.devicecatalog"), + ), + ], + ), + migrations.RunPython(ensure_device_catalogs_m2m_table, migrations.RunPython.noop), + migrations.RunPython(copy_sensor_catalog_to_device_catalogs, migrations.RunPython.noop), + ] diff --git a/Modules/Backend/device_hub/migrations/0009_sync_devicecatalog_schema.py b/Modules/Backend/device_hub/migrations/0009_sync_devicecatalog_schema.py new file mode 100644 index 0000000..219c596 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/0009_sync_devicecatalog_schema.py @@ -0,0 +1,47 @@ +from django.db import migrations, models + + +def add_column_if_missing(schema_editor, table_name, column_name, field): + existing_columns = { + column.name + for column in schema_editor.connection.introspection.get_table_description( + schema_editor.connection.cursor(), + table_name, + ) + } + if column_name in existing_columns: + return + field.set_attributes_from_name(column_name) + schema_editor.add_field( + field.model, + field, + ) + + +def sync_devicecatalog_schema(apps, schema_editor): + DeviceCatalog = apps.get_model("device_hub", "DeviceCatalog") + table_name = DeviceCatalog._meta.db_table + + fields = [ + DeviceCatalog._meta.get_field("device_communication_type"), + DeviceCatalog._meta.get_field("payload_mapping"), + DeviceCatalog._meta.get_field("display_schema"), + DeviceCatalog._meta.get_field("supported_widgets"), + DeviceCatalog._meta.get_field("commands_schema"), + DeviceCatalog._meta.get_field("capabilities"), + ] + + for field in fields: + add_column_if_missing(schema_editor, table_name, field.column, field) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("device_hub", "0008_farmdevice_device_catalogs"), + ] + + operations = [ + migrations.RunPython(sync_devicecatalog_schema, migrations.RunPython.noop), + ] diff --git a/Modules/Backend/device_hub/migrations/__init__.py b/Modules/Backend/device_hub/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/device_hub/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/device_hub/mock_data.py b/Modules/Backend/device_hub/mock_data.py new file mode 100644 index 0000000..003de29 --- /dev/null +++ b/Modules/Backend/device_hub/mock_data.py @@ -0,0 +1,57 @@ +AVG_SOIL_MOISTURE = { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": "45%", + "avatarColor": "primary", + "avatarIcon": "tabler-droplet", + "chipText": "متوسط", + "chipColor": "warning", +} + + +SENSOR_VALUES_LIST = { + "sensor": { + "name": "سنسور 7 در 1 خاک", + "physicalDeviceUuid": None, + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": None, + }, + "sensors": [ + {"id": "soil_moisture", "title": "45%", "subtitle": "رطوبت خاک", "trendNumber": 1.5, "trend": "positive", "unit": "%"}, + {"id": "soil_temperature", "title": "22.5°C", "subtitle": "دمای خاک", "trendNumber": 0.8, "trend": "positive", "unit": "°C"}, + {"id": "soil_ph", "title": "6.8", "subtitle": "pH خاک", "trendNumber": 0.1, "trend": "positive", "unit": "pH"}, + {"id": "electrical_conductivity", "title": "1.2 dS/m", "subtitle": "هدایت الکتریکی", "trendNumber": -0.1, "trend": "negative", "unit": "dS/m"}, + {"id": "nitrogen", "title": "30 mg/kg", "subtitle": "نیتروژن", "trendNumber": 2.0, "trend": "positive", "unit": "mg/kg"}, + {"id": "phosphorus", "title": "15 mg/kg", "subtitle": "فسفر", "trendNumber": 1.0, "trend": "positive", "unit": "mg/kg"}, + {"id": "potassium", "title": "20 mg/kg", "subtitle": "پتاسیم", "trendNumber": -1.0, "trend": "negative", "unit": "mg/kg"}, + ], +} + + +SENSOR_RADAR_CHART = { + "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], + "series": [{"name": "اکنون", "data": [82, 76, 90, 72, 68, 62, 70]}, {"name": "هدف", "data": [100, 100, 100, 100, 100, 100, 100]}], +} + + +SENSOR_COMPARISON_CHART = { + "currentValue": 45, + "vsLastWeek": "+4.7%", + "vsLastWeekValue": 4.7, + "categories": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"], + "series": [{"name": "رطوبت خاک", "data": [42, 44, 45, 47, 46, 45, 45]}, {"name": "بازه هدف", "data": [55, 55, 55, 55, 55, 55, 55]}], +} + + +ANOMALY_DETECTION_CARD = { + "anomalies": [{"sensor": "هدایت الکتریکی", "value": "1.2 dS/m", "expected": "0.8-1.1 dS/m", "deviation": "+0.1 dS/m", "severity": "warning"}] +} + + +SOIL_MOISTURE_HEATMAP = { + "zones": ["سنسور 7 در 1 خاک"], + "hours": ["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00"], + "series": [{"name": "سنسور 7 در 1 خاک", "data": [{"x": "08:00", "y": 42}, {"x": "10:00", "y": 44}, {"x": "12:00", "y": 45}, {"x": "14:00", "y": 47}, {"x": "16:00", "y": 46}, {"x": "18:00", "y": 45}, {"x": "20:00", "y": 45}]}], +} + diff --git a/Modules/Backend/device_hub/models.py b/Modules/Backend/device_hub/models.py new file mode 100644 index 0000000..5b0c3ad --- /dev/null +++ b/Modules/Backend/device_hub/models.py @@ -0,0 +1,106 @@ +import uuid as uuid_lib + +from django.db import models + + +class DeviceCatalog(models.Model): + OUTPUT_ONLY = "output_only" + INPUT_ONLY = "input_only" + DEVICE_COMMUNICATION_TYPES = [ + (OUTPUT_ONLY, "Output Only"), + (INPUT_ONLY, "Input Only"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + code = models.CharField(max_length=255, unique=True, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) + description = models.TextField(blank=True, default="") + device_communication_type = models.CharField( + max_length=32, + choices=DEVICE_COMMUNICATION_TYPES, + default=OUTPUT_ONLY, + db_index=True, + ) + customizable_fields = models.JSONField(default=list, blank=True) + supported_power_sources = models.JSONField(default=list, blank=True) + returned_data_fields = models.JSONField(default=list, blank=True) + payload_mapping = models.JSONField(default=dict, blank=True) + display_schema = models.JSONField(default=dict, blank=True) + supported_widgets = models.JSONField(default=list, blank=True) + commands_schema = models.JSONField(default=list, blank=True) + capabilities = models.JSONField(default=list, blank=True) + sample_payload = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "sensor_catalogs" + ordering = ["code"] + + def __str__(self): + return self.name + + +class FarmDevice(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="sensors") + sensor_catalog = models.ForeignKey( + DeviceCatalog, + on_delete=models.PROTECT, + related_name="farm_devices", + null=True, + blank=True, + ) + device_catalogs = models.ManyToManyField( + DeviceCatalog, + related_name="composite_farm_devices", + blank=True, + ) + physical_device_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, db_index=True) + name = models.CharField(max_length=255) + sensor_type = models.CharField(max_length=255, blank=True, default="") + is_active = models.BooleanField(default=True) + specifications = models.JSONField(default=dict, blank=True) + power_source = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_sensors" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.uuid})" + + def get_device_catalogs(self): + catalogs = list(self.device_catalogs.all()) + if catalogs: + return catalogs + if self.sensor_catalog_id: + return [self.sensor_catalog] + return [] + + def get_device_catalog_by_code(self, code): + if not code: + return None + normalized_code = str(code).strip().lower() + for catalog in self.get_device_catalogs(): + if catalog.code.lower() == normalized_code: + return catalog + return None + + +class SensorExternalRequestLog(models.Model): + farm_uuid = models.UUIDField(db_index=True) + sensor_catalog_uuid = models.UUIDField(null=True, blank=True, db_index=True) + physical_device_uuid = models.UUIDField(db_index=True) + payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "sensor_external_request_logs" + ordering = ["-created_at", "-id"] + + def __str__(self): + return f"{self.physical_device_uuid}:{self.created_at.isoformat()}" diff --git a/Modules/Backend/device_hub/seeds.py b/Modules/Backend/device_hub/seeds.py new file mode 100644 index 0000000..a7851b2 --- /dev/null +++ b/Modules/Backend/device_hub/seeds.py @@ -0,0 +1,60 @@ +from datetime import timedelta +import uuid + +from django.db import transaction +from django.utils import timezone + +from farm_hub.seeds import seed_admin_farm + +from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog + + +SENSOR_7_IN_1_CATALOG_CODE = "sensor-7-in-1" +SENSOR_7_IN_1_DEVICE_UUID = uuid.UUID("77777777-7777-7777-7777-777777777777") +SENSOR_7_IN_1_LOG_SERIES = [ + {"days_ago": 6, "payload": {"soil_moisture": 44.0, "soil_temperature": 20.6, "soil_ph": 6.3, "electrical_conductivity": 1.0, "nitrogen": 25.0, "phosphorus": 13.0, "potassium": 21.0}}, + {"days_ago": 5, "payload": {"soil_moisture": 45.5, "soil_temperature": 21.1, "soil_ph": 6.4, "electrical_conductivity": 1.1, "nitrogen": 26.0, "phosphorus": 13.8, "potassium": 21.8}}, + {"days_ago": 4, "payload": {"soil_moisture": 46.8, "soil_temperature": 21.7, "soil_ph": 6.5, "electrical_conductivity": 1.1, "nitrogen": 27.4, "phosphorus": 14.2, "potassium": 22.5}}, + {"days_ago": 3, "payload": {"soil_moisture": 48.2, "soil_temperature": 22.0, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 28.9, "phosphorus": 15.1, "potassium": 23.3}}, + {"days_ago": 2, "payload": {"soil_moisture": 49.6, "soil_temperature": 22.4, "soil_ph": 6.6, "electrical_conductivity": 1.2, "nitrogen": 29.7, "phosphorus": 15.7, "potassium": 24.1}}, + {"days_ago": 1, "payload": {"soil_moisture": 50.9, "soil_temperature": 22.8, "soil_ph": 6.7, "electrical_conductivity": 1.3, "nitrogen": 30.8, "phosphorus": 16.2, "potassium": 24.8}}, + {"days_ago": 0, "payload": {"soil_moisture": 52.4, "soil_temperature": 23.1, "soil_ph": 6.8, "electrical_conductivity": 1.3, "nitrogen": 32.0, "phosphorus": 16.8, "potassium": 25.6}}, +] + + +def seed_sensor_7_in_1_catalog(): + sensor_catalog, created = DeviceCatalog.objects.update_or_create( + code=SENSOR_7_IN_1_CATALOG_CODE, + defaults={ + "name": "Sensor 7 in 1 Soil Sensor", + "description": "Demo 7 in 1 soil sensor for dashboard summary and chart endpoints.", + "device_communication_type": "output_only", + "customizable_fields": [], + "supported_power_sources": ["solar", "battery", "direct_power"], + "returned_data_fields": ["soil_moisture", "soil_temperature", "soil_ph", "electrical_conductivity", "nitrogen", "phosphorus", "potassium"], + "sample_payload": SENSOR_7_IN_1_LOG_SERIES[-1]["payload"], + "is_active": True, + }, + ) + return sensor_catalog, created + + +@transaction.atomic +def seed_sensor_7_in_1_demo_data(): + farm, _ = seed_admin_farm() + sensor_catalog, catalog_created = seed_sensor_7_in_1_catalog() + sensor, sensor_created = FarmDevice.objects.update_or_create( + farm=farm, + physical_device_uuid=SENSOR_7_IN_1_DEVICE_UUID, + defaults={"sensor_catalog": sensor_catalog, "name": "Sensor 7 in 1 Demo", "sensor_type": "soil_7_in_1", "is_active": True, "specifications": {"capabilities": sensor_catalog.returned_data_fields, "demo_seed": True}, "power_source": {"type": "solar"}}, + ) + SensorExternalRequestLog.objects.filter(farm_uuid=farm.farm_uuid, physical_device_uuid=sensor.physical_device_uuid).delete() + base_time = timezone.now().replace(hour=12, minute=0, second=0, microsecond=0) + created_logs = [] + for item in SENSOR_7_IN_1_LOG_SERIES: + log = SensorExternalRequestLog.objects.create(farm_uuid=farm.farm_uuid, sensor_catalog_uuid=sensor_catalog.uuid, physical_device_uuid=sensor.physical_device_uuid, payload=item["payload"]) + created_at = base_time - timedelta(days=item["days_ago"]) + SensorExternalRequestLog.objects.filter(id=log.id).update(created_at=created_at) + log.created_at = created_at + created_logs.append(log) + return {"farm": farm, "sensor_catalog": sensor_catalog, "sensor": sensor, "catalog_created": catalog_created, "sensor_created": sensor_created, "log_count": len(created_logs)} diff --git a/Modules/Backend/device_hub/sensor_7_in_1_urls.py b/Modules/Backend/device_hub/sensor_7_in_1_urls.py new file mode 100644 index 0000000..7d9b79f --- /dev/null +++ b/Modules/Backend/device_hub/sensor_7_in_1_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import Sensor7In1ComparisonChartView, Sensor7In1RadarChartView, Sensor7In1SummaryView + +urlpatterns = [ + path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), + path("radar-chart/", Sensor7In1RadarChartView.as_view(), name="sensor-7-in-1-radar-chart"), + path("comparison-chart/", Sensor7In1ComparisonChartView.as_view(), name="sensor-7-in-1-comparison-chart"), +] diff --git a/Modules/Backend/device_hub/sensor_catalog_urls.py b/Modules/Backend/device_hub/sensor_catalog_urls.py new file mode 100644 index 0000000..7c5747d --- /dev/null +++ b/Modules/Backend/device_hub/sensor_catalog_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import DeviceCatalogListView + +urlpatterns = [ + path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"), +] diff --git a/Modules/Backend/device_hub/sensor_external_api_urls.py b/Modules/Backend/device_hub/sensor_external_api_urls.py new file mode 100644 index 0000000..3f80695 --- /dev/null +++ b/Modules/Backend/device_hub/sensor_external_api_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import SensorExternalAPIView, SensorExternalRequestLogListAPIView + +urlpatterns = [ + path("", SensorExternalAPIView.as_view(), name="sensor-external-api"), + path("logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), +] + diff --git a/Modules/Backend/device_hub/sensor_serializers.py b/Modules/Backend/device_hub/sensor_serializers.py new file mode 100644 index 0000000..e3da0d8 --- /dev/null +++ b/Modules/Backend/device_hub/sensor_serializers.py @@ -0,0 +1,111 @@ +from rest_framework import serializers + +from soil.serializers import SoilAnomalyDetectionSerializer, SoilComparisonChartSerializer, SoilKpiSerializer, SoilMoistureHeatmapSerializer, SoilRadarChartSerializer + + +class Sensor7In1MetaSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + physicalDeviceUuid = serializers.CharField(required=False, allow_null=True) + sensorCatalogCode = serializers.CharField(required=False, allow_blank=True) + updatedAt = serializers.CharField(required=False, allow_null=True) + + +class Sensor7In1ValueSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + trendNumber = serializers.FloatField(required=False) + trend = serializers.CharField(required=False, allow_blank=True) + unit = serializers.CharField(required=False, allow_blank=True) + + +class Sensor7In1ValuesListSerializer(serializers.Serializer): + sensor = Sensor7In1MetaSerializer(required=False) + sensors = Sensor7In1ValueSerializer(many=True, required=False) + + +class SensorComparisonChartQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["7d", "30d"], required=False, default="7d") + + +class SensorValuesListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["1h", "24h", "7d"], required=False, default="7d") + + +class SensorRadarChartQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + range = serializers.ChoiceField(choices=["today", "7d", "30d"], required=False, default="7d") + + +class SensorComparisonChartSeriesSerializer(serializers.Serializer): + name = serializers.CharField() + data = serializers.ListField(child=serializers.FloatField()) + + +class SensorComparisonChartResponseSerializer(serializers.Serializer): + series = SensorComparisonChartSeriesSerializer(many=True) + categories = serializers.ListField(child=serializers.CharField()) + currentValue = serializers.FloatField() + vsLastWeek = serializers.CharField() + + +class SensorValuesListItemSerializer(serializers.Serializer): + title = serializers.CharField() + subtitle = serializers.CharField() + trendNumber = serializers.FloatField() + trend = serializers.ChoiceField(choices=["positive", "negative"]) + unit = serializers.CharField() + + +class SensorValuesListResponseSerializer(serializers.Serializer): + sensors = SensorValuesListItemSerializer(many=True) + + +class SensorRadarChartResponseSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField()) + series = SensorComparisonChartSeriesSerializer(many=True) + + +class Sensor7In1SummarySerializer(serializers.Serializer): + sensor = Sensor7In1MetaSerializer(required=False) + sensorValuesList = Sensor7In1ValuesListSerializer(required=False) + avgSoilMoisture = SoilKpiSerializer(required=False) + sensorRadarChart = SoilRadarChartSerializer(required=False) + sensorComparisonChart = SoilComparisonChartSerializer(required=False) + anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) + soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) + + +class DeviceMetaSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + physicalDeviceUuid = serializers.CharField(required=False, allow_null=True) + sensorCatalogCode = serializers.CharField(required=False, allow_blank=True) + updatedAt = serializers.CharField(required=False, allow_null=True) + + +class DeviceFieldValueSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + trendNumber = serializers.FloatField(required=False) + trend = serializers.CharField(required=False, allow_blank=True) + unit = serializers.CharField(required=False, allow_blank=True) + + +class DeviceValuesListSerializer(serializers.Serializer): + sensor = DeviceMetaSerializer(required=False) + sensors = DeviceFieldValueSerializer(many=True, required=False) + + +class DeviceSummarySerializer(serializers.Serializer): + sensor = DeviceMetaSerializer(required=False) + supportedWidgets = serializers.ListField(child=serializers.CharField(), required=False) + sensorValuesList = DeviceValuesListSerializer(required=False) + avgSoilMoisture = SoilKpiSerializer(required=False) + sensorRadarChart = SoilRadarChartSerializer(required=False) + sensorComparisonChart = SoilComparisonChartSerializer(required=False) + anomalyDetectionCard = SoilAnomalyDetectionSerializer(required=False) + soilMoistureHeatmap = SoilMoistureHeatmapSerializer(required=False) + commands = serializers.ListField(child=serializers.JSONField(), required=False) diff --git a/Modules/Backend/device_hub/serializers.py b/Modules/Backend/device_hub/serializers.py new file mode 100644 index 0000000..3799a7d --- /dev/null +++ b/Modules/Backend/device_hub/serializers.py @@ -0,0 +1,196 @@ +from rest_framework import serializers + +from .models import DeviceCatalog, FarmDevice, SensorExternalRequestLog + + +class DeviceCatalogSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceCatalog + fields = [ + "uuid", + "code", + "name", + "description", + "device_communication_type", + "customizable_fields", + "supported_power_sources", + "returned_data_fields", + "payload_mapping", + "display_schema", + "supported_widgets", + "commands_schema", + "capabilities", + "sample_payload", + "is_active", + ] + read_only_fields = fields + + +class SensorExternalRequestSerializer(serializers.Serializer): + uuid = serializers.UUIDField() + payload = serializers.JSONField(required=False, default=dict) + + +class SensorExternalRequestLogQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() + page = serializers.IntegerField(min_value=1) + page_size = serializers.IntegerField(min_value=1, max_value=100) + physical_device_uuid = serializers.UUIDField(required=False) + sensor_type = serializers.CharField(required=False, allow_blank=False) + date_from = serializers.DateField(required=False) + date_to = serializers.DateField(required=False) + + def validate(self, attrs): + date_from = attrs.get("date_from") + date_to = attrs.get("date_to") + if date_from and date_to and date_from > date_to: + raise serializers.ValidationError({"date_to": "date_to must be greater than or equal to date_from."}) + return attrs + + +class FarmDeviceLogSerializer(serializers.ModelSerializer): + sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) + device_catalogs = DeviceCatalogSerializer(many=True, read_only=True) + + class Meta: + model = FarmDevice + fields = [ + "uuid", + "sensor_catalog_uuid", + "device_catalogs", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "created_at", + "updated_at", + ] + + +class DeviceCatalogLogSerializer(serializers.ModelSerializer): + class Meta: + model = DeviceCatalog + fields = [ + "uuid", + "code", + "name", + "description", + "device_communication_type", + "customizable_fields", + "supported_power_sources", + "returned_data_fields", + "payload_mapping", + "display_schema", + "supported_widgets", + "commands_schema", + "capabilities", + "sample_payload", + "is_active", + "created_at", + "updated_at", + ] + + +class DeviceDetailSerializer(serializers.ModelSerializer): + device_catalog = DeviceCatalogSerializer(source="sensor_catalog", read_only=True) + device_catalogs = serializers.SerializerMethodField() + last_payload_at = serializers.SerializerMethodField() + + class Meta: + model = FarmDevice + fields = [ + "uuid", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "device_catalog", + "device_catalogs", + "last_payload_at", + "created_at", + "updated_at", + ] + + def get_last_payload_at(self, obj): + latest_log = self.context.get("latest_log") + if latest_log is None: + return None + return latest_log.created_at + + def get_device_catalogs(self, obj): + return DeviceCatalogSerializer(obj.get_device_catalogs(), many=True).data + + +class DeviceLatestPayloadSerializer(serializers.Serializer): + physical_device_uuid = serializers.UUIDField() + device_code = serializers.CharField() + device_catalog_code = serializers.CharField(allow_blank=True, allow_null=True) + raw_payload = serializers.JSONField() + normalized_payload = serializers.JSONField() + readings = serializers.JSONField() + created_at = serializers.DateTimeField(allow_null=True) + + +class DeviceCommandRequestSerializer(serializers.Serializer): + device_code = serializers.CharField() + command = serializers.CharField() + payload = serializers.JSONField(required=False, default=dict) + + +class DeviceCodeQuerySerializer(serializers.Serializer): + device_code = serializers.CharField() + + +class DeviceRangeQuerySerializer(DeviceCodeQuerySerializer): + range = serializers.CharField() + + +class DeviceCommandResponseSerializer(serializers.Serializer): + physical_device_uuid = serializers.UUIDField() + command = serializers.CharField() + status = serializers.CharField() + + +class DeviceCodeListResponseSerializer(serializers.Serializer): + physical_device_uuid = serializers.UUIDField() + device_codes = serializers.ListField(child=serializers.CharField()) + + +class SensorExternalRequestLogSerializer(serializers.ModelSerializer): + farm_device = serializers.SerializerMethodField() + sensor_catalog = serializers.SerializerMethodField() + + class Meta: + model = SensorExternalRequestLog + fields = [ + "id", + "farm_uuid", + "sensor_catalog_uuid", + "physical_device_uuid", + "farm_device", + "sensor_catalog", + "payload", + "created_at", + ] + + def get_farm_device(self, obj): + farm_device_map = self.context.get("farm_device_map", {}) + farm_device = farm_device_map.get( + (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) + ) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) + if farm_device is None: + return None + return FarmDeviceLogSerializer(farm_device).data + + def get_sensor_catalog(self, obj): + farm_device_map = self.context.get("farm_device_map", {}) + farm_device = farm_device_map.get( + (obj.farm_uuid, obj.sensor_catalog_uuid, obj.physical_device_uuid) + ) or farm_device_map.get((obj.farm_uuid, None, obj.physical_device_uuid)) + if farm_device is None or farm_device.sensor_catalog is None: + return None + return DeviceCatalogLogSerializer(farm_device.sensor_catalog).data diff --git a/Modules/Backend/device_hub/services.py b/Modules/Backend/device_hub/services.py new file mode 100644 index 0000000..9308969 --- /dev/null +++ b/Modules/Backend/device_hub/services.py @@ -0,0 +1,1199 @@ +from copy import deepcopy +from datetime import timedelta +import logging + +from django.conf import settings +from django.db import OperationalError, ProgrammingError, transaction +from django.utils import timezone + +from config.failure_contract import StructuredServiceError +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError +from notifications.services import create_notification_for_farm_uuid + +from .models import FarmDevice, SensorExternalRequestLog +from .templates import AVG_SOIL_MOISTURE_TEMPLATE, SENSOR_META_TEMPLATE, SOIL_MOISTURE_HEATMAP_TEMPLATE + +logger = logging.getLogger(__name__) + + +class FarmDataForwardError(Exception): + pass + + +class DeviceDataUnavailableError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, details: dict | None = None, retriable: bool = False): + super().__init__( + error_code=error_code, + message=message, + source="db", + details=details, + retriable=retriable, + ) + +SENSOR_FIELDS = [ + {"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "payload_keys": ("soil_moisture", "soilMoisture", "moisture"), "ideal_min": 45.0, "ideal_max": 65.0, "radar_label": "رطوبت"}, + {"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "payload_keys": ("soil_temperature", "soilTemperature", "temperature"), "ideal_min": 18.0, "ideal_max": 28.0, "radar_label": "دما"}, + {"id": "soil_ph", "label": "pH خاک", "unit": "pH", "payload_keys": ("soil_ph", "soilPh", "ph"), "ideal_min": 6.0, "ideal_max": 7.5, "radar_label": "pH"}, + {"id": "electrical_conductivity", "label": "هدایت الکتریکی", "unit": "dS/m", "payload_keys": ("electrical_conductivity", "electricalConductivity", "ec"), "ideal_min": 0.8, "ideal_max": 1.8, "radar_label": "EC"}, + {"id": "nitrogen", "label": "نیتروژن", "unit": "mg/kg", "payload_keys": ("nitrogen", "n"), "ideal_min": 20.0, "ideal_max": 40.0, "radar_label": "نیتروژن"}, + {"id": "phosphorus", "label": "فسفر", "unit": "mg/kg", "payload_keys": ("phosphorus", "p"), "ideal_min": 10.0, "ideal_max": 25.0, "radar_label": "فسفر"}, + {"id": "potassium", "label": "پتاسیم", "unit": "mg/kg", "payload_keys": ("potassium", "k"), "ideal_min": 15.0, "ideal_max": 35.0, "radar_label": "پتاسیم"}, +] +MAX_HISTORY_ITEMS = 20 +MAX_CHART_POINTS = 7 +COMPARISON_CHART_RANGES = {"7d": 7, "30d": 30} +VALUES_LIST_RANGES = {"1h": timedelta(hours=1), "24h": timedelta(hours=24), "7d": timedelta(days=7)} +RADAR_CHART_RANGES = {"today": timedelta(days=1), "7d": timedelta(days=7), "30d": timedelta(days=30)} +PERSIAN_WEEKDAYS = {0: "دوشنبه", 1: "سه شنبه", 2: "چهارشنبه", 3: "پنج شنبه", 4: "جمعه", 5: "شنبه", 6: "یکشنبه"} +COMPARISON_CHART_FIELD_ALIASES = {"soil_moisture": "moisture", "soilMoisture": "moisture", "moisture": "moisture", "soil_temperature": "temperature", "soilTemperature": "temperature", "temperature": "temperature", "humidity": "humidity", "soil_ph": "ph", "soilPh": "ph", "ph": "ph", "electrical_conductivity": "ec", "electricalConductivity": "ec", "ec": "ec", "nitrogen": "nitrogen", "n": "nitrogen", "phosphorus": "phosphorus", "p": "phosphorus", "potassium": "potassium", "k": "potassium"} +COMPARISON_CHART_PRIMARY_FIELDS = ("moisture", "temperature", "humidity", "ph", "ec", "nitrogen", "phosphorus", "potassium") +VALUES_LIST_FIELDS = [("moisture", "Moisture", "%"), ("temperature", "Temperature", "°C"), ("humidity", "Humidity", "%"), ("ph", "pH", "pH"), ("ec", "EC", "dS/m"), ("nitrogen", "Nitrogen", "mg/kg"), ("phosphorus", "Phosphorus", "mg/kg"), ("potassium", "Potassium", "mg/kg")] +RADAR_CHART_FIELDS = [("moisture", "Moisture", 60.0), ("temperature", "Temperature", 26.0), ("humidity", "Humidity", 55.0), ("ph", "PH", 6.5), ("ec", "EC", 1.3), ("nitrogen", "Nitrogen", 42.0), ("potassium", "Potassium", 38.0)] + + +def get_sensor_external_request_logs_for_farm(*, farm_uuid, physical_device_uuid=None, sensor_type=None, date_from=None, date_to=None): + try: + queryset = SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid) + if physical_device_uuid: + queryset = queryset.filter(physical_device_uuid=physical_device_uuid) + if sensor_type: + physical_device_uuids = FarmDevice.objects.filter(farm__farm_uuid=farm_uuid, sensor_type=sensor_type).values_list("physical_device_uuid", flat=True) + queryset = queryset.filter(physical_device_uuid__in=physical_device_uuids) + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + return queryset.order_by("-created_at", "-id") + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def get_farm_device_map_for_logs(*, logs): + try: + logs = list(logs) + if not logs: + return {} + farm_device_queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter( + farm__farm_uuid__in={log.farm_uuid for log in logs}, + physical_device_uuid__in={log.physical_device_uuid for log in logs}, + ).order_by("-created_at", "-id") + farm_device_map = {} + for farm_device in farm_device_queryset: + exact_key = (farm_device.farm.farm_uuid, farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None, farm_device.physical_device_uuid) + fallback_key = (farm_device.farm.farm_uuid, None, farm_device.physical_device_uuid) + farm_device_map.setdefault(exact_key, farm_device) + farm_device_map.setdefault(fallback_key, farm_device) + return farm_device_map + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def get_latest_sensor_external_request_log(*, farm_uuid, sensor_catalog_uuid, physical_device_uuid): + try: + return SensorExternalRequestLog.objects.filter(farm_uuid=farm_uuid, sensor_catalog_uuid=sensor_catalog_uuid, physical_device_uuid=physical_device_uuid).order_by("-created_at", "-id").first() + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def create_sensor_external_notification(*, physical_device_uuid, payload=None): + payload = payload or {} + sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() + if sensor is None: + raise ValueError("Physical device not found.") + try: + with transaction.atomic(): + SensorExternalRequestLog.objects.create( + farm_uuid=sensor.farm.farm_uuid, + sensor_catalog_uuid=sensor.sensor_catalog.uuid if sensor.sensor_catalog else None, + physical_device_uuid=sensor.physical_device_uuid, + payload=payload, + ) + return create_notification_for_farm_uuid( + farm_uuid=sensor.farm.farm_uuid, + title="Sensor external API request", + message=f"Payload received from device {sensor.physical_device_uuid}.", + level="info", + metadata={"farm_uuid": str(sensor.farm.farm_uuid), "sensor_catalog_uuid": str(sensor.sensor_catalog.uuid) if sensor.sensor_catalog else None, "physical_device_uuid": str(sensor.physical_device_uuid), "payload": payload}, + ) + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Sensor external API tables are not migrated.") from exc + + +def forward_sensor_payload_to_farm_data(*, physical_device_uuid, payload=None): + payload = payload or {} + sensor = FarmDevice.objects.select_related("farm", "farm__current_crop_area", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid).first() + if sensor is None: + raise ValueError("Physical device not found.") + farm_boundary = _get_farm_boundary(sensor=sensor) + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + raise FarmDataForwardError("FARM_DATA_API_KEY is not configured.") + sensor_key = _get_sensor_key(sensor=sensor) + normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=payload) + request_payload = {"farm_uuid": str(sensor.farm.farm_uuid), "farm_boundary": farm_boundary, "sensor_key": sensor_key, "sensor_payload": normalized_sensor_payload} + try: + response = external_api_request( + "ai", + _get_farm_data_path(), + method="POST", + payload=request_payload, + headers={"Accept": "application/json", "Content-Type": "application/json", "X-API-Key": api_key, "Authorization": f"Api-Key {api_key}"}, + ) + except ExternalAPIRequestError as exc: + raise FarmDataForwardError(f"Farm data API request failed: {exc}") from exc + if response.status_code >= 400: + raise FarmDataForwardError(f"Farm data API returned status {response.status_code}: {response.data}") + return request_payload + + +def _get_farm_boundary(*, sensor): + crop_area = sensor.farm.current_crop_area or sensor.farm.crop_areas.order_by("-created_at", "-id").first() + if crop_area is None: + raise FarmDataForwardError("Farm boundary is not configured for this farm.") + geometry = crop_area.geometry or {} + if geometry.get("type") == "Feature": + geometry = geometry.get("geometry") or {} + if geometry.get("type") != "Polygon": + raise FarmDataForwardError("Farm boundary geometry must be a Polygon.") + return geometry + + +def _normalize_sensor_payload(*, sensor_key, sensor_payload): + if not sensor_payload: + return {} + if not isinstance(sensor_payload, dict): + raise FarmDataForwardError("`payload` must be a JSON object.") + if all(isinstance(value, dict) for value in sensor_payload.values()): + return sensor_payload + return {sensor_key: sensor_payload} + + +def _get_sensor_key(*, sensor): + if sensor.sensor_catalog and sensor.sensor_catalog.code: + return sensor.sensor_catalog.code + return "sensor-7-1" + + +def _get_farm_data_path(): + return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") + + +def _to_float(value): + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _extract_payload(payload): + if not isinstance(payload, dict): + return {} + if isinstance(payload.get("payload"), dict): + payload = payload["payload"] + if isinstance(payload.get("data"), dict): + nested = payload["data"] + if any(any(key in nested for key in field["payload_keys"]) for field in SENSOR_FIELDS): + payload = nested + return payload + + +def _extract_numeric_payload(payload): + payload = _extract_payload(payload) + return {key: numeric_value for key, value in payload.items() if (numeric_value := _to_float(value)) is not None} + + +def _extract_readings(payload): + payload = _extract_payload(payload) + readings = {} + for field in SENSOR_FIELDS: + for key in field["payload_keys"]: + value = _to_float(payload.get(key)) + if value is not None: + readings[field["id"]] = value + break + return readings + + +def _format_number(value): + if value is None: + return "" + if float(value).is_integer(): + return str(int(value)) + return f"{value:.1f}".rstrip("0").rstrip(".") + + +def _format_value(value, unit): + number = _format_number(value) + if not number: + return number + if unit in {"", "pH"}: + return number + if unit in {"%", "°C"}: + return f"{number}{unit}" + return f"{number} {unit}" + + +def _format_range(field): + lower = _format_number(field["ideal_min"]) + upper = _format_number(field["ideal_max"]) + if field["unit"] in {"", "pH"}: + return f"{lower}-{upper}" + return f"{lower}-{upper} {field['unit']}" + + +def get_primary_soil_sensor(*, farm): + soil_sensors = list(farm.sensors.select_related("sensor_catalog").order_by("created_at", "id")) + + def _sensor_priority(sensor): + sensor_type = (sensor.sensor_type or "").lower() + catalog_code = (sensor.sensor_catalog.code if sensor.sensor_catalog else "").lower() + catalog_name = (sensor.sensor_catalog.name if sensor.sensor_catalog else "").lower() + sensor_name = (sensor.name or "").lower() + haystack = " ".join([sensor_type, catalog_code, catalog_name, sensor_name]) + if "sensor-7-in-1" in catalog_code or "soil_7_in_1" in sensor_type: + return 0 + if "7 in 1" in haystack or "7-in-1" in haystack or "7in1" in haystack: + return 1 + if "soil" in haystack: + return 2 + return 3 + + prioritized_sensors = sorted(soil_sensors, key=_sensor_priority) + if prioritized_sensors and _sensor_priority(prioritized_sensors[0]) < 3: + return prioritized_sensors[0] + return soil_sensors[0] if soil_sensors else None + + +def _get_sensor_context(farm=None): + if farm is None: + raise DeviceDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for sensor context lookup.", + ) + primary_sensor = get_primary_soil_sensor(farm=farm) + if primary_sensor is None: + raise DeviceDataUnavailableError( + error_code="sensor_not_found", + message=f"No primary soil sensor found for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + ) + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=primary_sensor.physical_device_uuid) + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Sensor history lookup failed for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + retriable=True, + ) from exc + history = [] + for log in logs_queryset[:MAX_HISTORY_ITEMS]: + readings = _extract_readings(log.payload) + if readings: + history.append((log, readings)) + if not history: + raise DeviceDataUnavailableError( + error_code="no_sensor_readings", + message=f"No sensor readings found for farm_uuid={farm.farm_uuid}.", + details={"farm_uuid": str(farm.farm_uuid)}, + ) + latest_log, latest_readings = history[0] + farm_device_map = get_farm_device_map_for_logs(logs=[latest_log]) + farm_device = farm_device_map.get((latest_log.farm_uuid, latest_log.sensor_catalog_uuid, latest_log.physical_device_uuid)) or primary_sensor + return {"farm_device": farm_device, "latest_log": latest_log, "latest_readings": latest_readings, "previous_readings": history[1][1] if len(history) > 1 else {}, "history": history} + + +def _build_sensor_meta(context, fallback_sensor): + sensor = deepcopy(fallback_sensor) + if not context: + return sensor + farm_device = context.get("farm_device") + latest_log = context["latest_log"] + sensor["physicalDeviceUuid"] = str(latest_log.physical_device_uuid) + sensor["updatedAt"] = latest_log.created_at.isoformat() + if farm_device is not None: + sensor["name"] = farm_device.name or sensor["name"] + if farm_device.sensor_catalog is not None: + sensor["sensorCatalogCode"] = farm_device.sensor_catalog.code + return sensor + + +def _calculate_status_chip(value): + if value is None: + return ("نامشخص", "secondary", "secondary") + if value >= 60: + return ("بهینه", "success", "primary") + if value >= 45: + return ("متوسط", "warning", "warning") + return ("کم", "error", "error") + + +def get_sensor_7_in_1_values_list_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + data = { + "sensor": _build_sensor_meta(context, SENSOR_META_TEMPLATE), + "sensors": [], + } + latest_readings = context["latest_readings"] + previous_readings = context["previous_readings"] + for field in SENSOR_FIELDS: + value = latest_readings.get(field["id"]) + if value is None: + continue + previous = previous_readings.get(field["id"]) + change = 0.0 if previous is None else round(value - previous, 2) + data["sensors"].append({"id": field["id"], "title": _format_value(value, field["unit"]), "subtitle": field["label"], "trendNumber": abs(change), "trend": "positive" if change >= 0 else "negative", "unit": field["unit"]}) + if not data["sensors"]: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"Latest sensor payload has no usable numeric values for farm_uuid={farm.farm_uuid if farm else None}.", + ) + return data + + +def get_sensor_7_in_1_avg_soil_moisture_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + moisture = context["latest_readings"].get("soil_moisture") + if moisture is None: + raise DeviceDataUnavailableError( + error_code="missing_soil_moisture", + message=f"Latest sensor payload is missing soil_moisture for farm_uuid={farm.farm_uuid if farm else None}.", + ) + chip_text, chip_color, avatar_color = _calculate_status_chip(moisture) + return { + **deepcopy(AVG_SOIL_MOISTURE_TEMPLATE), + "stats": _format_value(moisture, "%"), + "chipText": chip_text, + "chipColor": chip_color, + "avatarColor": avatar_color, + "status": "success", + "source": "db", + } + + +def _score_field(value, field): + min_value = field["ideal_min"] + max_value = field["ideal_max"] + midpoint = (min_value + max_value) / 2 + half_span = max((max_value - min_value) / 2, 0.1) + distance = abs(value - midpoint) + if min_value <= value <= max_value: + return round(max(80.0, 100.0 - ((distance / half_span) * 20.0)), 1) + overflow = max(0.0, distance - half_span) + return round(max(0.0, 80.0 - ((overflow / half_span) * 80.0)), 1) + + +def get_sensor_7_in_1_radar_chart_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + latest_readings = context["latest_readings"] + scores, labels = [], [] + for field in SENSOR_FIELDS: + value = latest_readings.get(field["id"]) + if value is None: + continue + labels.append(field["radar_label"]) + scores.append(_score_field(value, field)) + if not labels: + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"No usable sensor readings found for radar chart farm_uuid={farm.farm_uuid if farm else None}.", + ) + return { + "labels": labels, + "series": [{"name": "اکنون", "data": scores}, {"name": "هدف", "data": [100.0] * len(labels)}], + "status": "success", + "source": "db", + } + + +def get_sensor_7_in_1_comparison_chart_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + history = list(reversed(context["history"][:MAX_CHART_POINTS])) + moisture_points = [(log.created_at.strftime("%m/%d %H:%M"), readings.get("soil_moisture")) for log, readings in history if readings.get("soil_moisture") is not None] + if not moisture_points: + raise DeviceDataUnavailableError( + error_code="no_comparison_data", + message=f"No soil moisture history found for comparison chart farm_uuid={farm.farm_uuid if farm else None}.", + ) + categories = [item[0] for item in moisture_points] + values = [round(item[1], 2) for item in moisture_points] + current_value = values[-1] + baseline_value = values[0] if len(values) > 1 else 55.0 + percent_change = ((current_value - baseline_value) / baseline_value) * 100 if baseline_value else 0.0 + return { + "currentValue": round(current_value, 2), + "vsLastWeekValue": round(percent_change, 2), + "vsLastWeek": f"{percent_change:+.1f}%", + "categories": categories, + "series": [{"name": "رطوبت خاک", "data": values}, {"name": "بازه هدف", "data": [55.0] * len(values)}], + "status": "success", + "source": "db", + } + + +def _build_anomaly_item(field, value): + lower = field["ideal_min"] + upper = field["ideal_max"] + if lower <= value <= upper: + return None + deviation = value - upper if value > upper else value - lower + severity = "warning" + span = max(upper - lower, 0.1) + if abs(deviation) >= span * 0.5: + severity = "error" + sign = "+" if deviation > 0 else "" + return {"sensor": field["label"], "value": _format_value(value, field["unit"]), "expected": _format_range(field), "deviation": f"{sign}{_format_value(deviation, field['unit'])}", "severity": severity} + + +def get_sensor_7_in_1_anomaly_detection_card_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + anomalies = [] + for field in SENSOR_FIELDS: + value = context["latest_readings"].get(field["id"]) + if value is None: + continue + anomaly = _build_anomaly_item(field, value) + if anomaly is not None: + anomalies.append(anomaly) + return { + "anomalies": anomalies, + "status": "success", + "source": "db", + "warnings": [] if anomalies else ["No anomalies detected from the latest sensor readings."], + } + + +def get_sensor_7_in_1_soil_moisture_heatmap_data(farm=None, context=None): + context = _get_sensor_context(farm) if context is None else context + history = list(reversed(context["history"][:MAX_CHART_POINTS])) + chart_points = [{"x": log.created_at.strftime("%H:%M"), "y": round(readings.get("soil_moisture"), 2)} for log, readings in history if readings.get("soil_moisture") is not None] + if not chart_points: + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"No soil moisture history found for heatmap farm_uuid={farm.farm_uuid if farm else None}.", + ) + sensor_name = ( + SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"][0] + if SOIL_MOISTURE_HEATMAP_TEMPLATE["zones"] + else "سنسور خاک" + ) + farm_device = context.get("farm_device") + if farm_device is not None and farm_device.name: + sensor_name = farm_device.name + return { + "zones": [sensor_name], + "hours": [point["x"] for point in chart_points], + "series": [{"name": sensor_name, "data": chart_points}], + "status": "success", + "source": "db", + } + + +def get_sensor_7_in_1_summary_data(farm=None): + context = _get_sensor_context(farm) + values_list = get_sensor_7_in_1_values_list_data(farm, context=context) + return {"sensor": values_list["sensor"], "sensorValuesList": values_list, "avgSoilMoisture": get_sensor_7_in_1_avg_soil_moisture_data(farm, context=context), "sensorRadarChart": get_sensor_7_in_1_radar_chart_data(farm, context=context), "sensorComparisonChart": get_sensor_7_in_1_comparison_chart_data(farm, context=context), "anomalyDetectionCard": get_sensor_7_in_1_anomaly_detection_card_data(farm, context=context), "soilMoistureHeatmap": get_sensor_7_in_1_soil_moisture_heatmap_data(farm, context=context)} + + +def _normalize_comparison_chart_field(field_name): + return COMPARISON_CHART_FIELD_ALIASES.get(field_name, field_name) + + +def _format_comparison_category(bucket_date, range_value): + return PERSIAN_WEEKDAYS[bucket_date.weekday()] if range_value == "7d" else bucket_date.strftime("%m/%d") + + +def _format_percent_change(current_value, baseline_value): + if not baseline_value: + return "+0.0%" + return f"{((current_value - baseline_value) / baseline_value) * 100:+.1f}%" + + +def _format_current_value_subtitle(title, value, unit): + rendered_value = _format_value(value, unit) + return f"مقدار فعلی: {rendered_value or title}" + + +def get_sensor_comparison_chart_data(*, farm, physical_device_uuid, range_value): + days = COMPARISON_CHART_RANGES[range_value] + start_date = timezone.localdate() - timedelta(days=days - 1) + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid, date_from=start_date) + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor comparison chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc + grouped_logs = {} + for log in reversed(list(logs_queryset[: days * 24])): + bucket_date = timezone.localtime(log.created_at).date() + numeric_payload = _extract_numeric_payload(log.payload) + if numeric_payload: + grouped_logs[bucket_date] = numeric_payload + if not grouped_logs: + raise DeviceDataUnavailableError( + f"No sensor history found for comparison chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) + sorted_dates = sorted(grouped_logs.keys()) + categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] + series_map = {} + for bucket_date in sorted_dates: + normalized_payload = {_normalize_comparison_chart_field(key): value for key, value in grouped_logs[bucket_date].items()} + for key, value in normalized_payload.items(): + series_map.setdefault(key, []).append(round(value, 2)) + ordered_field_names = [field_name for field_name in COMPARISON_CHART_PRIMARY_FIELDS if field_name in series_map] + sorted(field_name for field_name in series_map if field_name not in COMPARISON_CHART_PRIMARY_FIELDS) + series = [{"name": field_name, "data": series_map[field_name]} for field_name in ordered_field_names] + primary_data = series_map[ordered_field_names[0]] + return {"series": series, "categories": categories, "currentValue": round(primary_data[-1], 2), "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0])} + + +def get_sensor_values_list_data(*, farm, physical_device_uuid, range_value): + start_time = timezone.now() - VALUES_LIST_RANGES[range_value] + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor values list data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc + logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) + if not logs: + latest_log = logs_queryset.order_by("-created_at", "-id").first() + if latest_log is None: + raise DeviceDataUnavailableError( + f"No sensor logs found for values list farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) + logs = [latest_log] + earliest_payload, latest_payload = {}, {} + for log in logs: + numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()} + if not numeric_payload: + continue + if not earliest_payload: + earliest_payload = numeric_payload + latest_payload = numeric_payload + if not latest_payload: + raise DeviceDataUnavailableError( + f"Latest sensor payload has no numeric values for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) + sensors = [] + for field_name, title, unit in VALUES_LIST_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + previous_value = earliest_payload.get(field_name, current_value) + delta = round(current_value - previous_value, 2) + sensors.append({"title": title, "subtitle": _format_current_value_subtitle(title, current_value, unit), "trendNumber": abs(delta), "trend": "positive" if delta >= 0 else "negative", "unit": unit}) + return {"sensors": sensors} + + +def get_sensor_radar_chart_data(*, farm, physical_device_uuid, range_value): + start_time = timezone.now() - RADAR_CHART_RANGES[range_value] + try: + logs_queryset = get_sensor_external_request_logs_for_farm(farm_uuid=farm.farm_uuid, physical_device_uuid=physical_device_uuid) + except ValueError as exc: + raise DeviceDataUnavailableError( + f"Sensor radar chart data is unavailable for farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) from exc + logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) + if not logs: + latest_log = logs_queryset.order_by("-created_at", "-id").first() + if latest_log is None: + raise DeviceDataUnavailableError( + f"No sensor logs found for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) + logs = [latest_log] + latest_payload = {} + for log in logs: + numeric_payload = {_normalize_comparison_chart_field(key): value for key, value in _extract_numeric_payload(log.payload).items()} + if numeric_payload: + latest_payload = numeric_payload + if not latest_payload: + raise DeviceDataUnavailableError( + f"Latest sensor payload has no numeric values for radar chart farm_uuid={farm.farm_uuid} device={physical_device_uuid}." + ) + labels, current_data, ideal_data = [], [], [] + for field_name, label, ideal_value in RADAR_CHART_FIELDS: + current_value = latest_payload.get(field_name) + if current_value is None: + continue + labels.append(label) + current_data.append(round(current_value, 2)) + ideal_data.append(round(ideal_value, 2)) + return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}]} + + +DEVICE_COMMAND_PAYLOAD_TYPES = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "object": dict, + "array": list, +} +DEFAULT_DEVICE_WIDGETS = [ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload", + "anomaly_card", + "soil_moisture_heatmap", +] + + +def get_farm_device_by_physical_uuid(*, physical_device_uuid, owner=None): + queryset = FarmDevice.objects.select_related("farm", "sensor_catalog").filter(physical_device_uuid=physical_device_uuid) + if owner is not None: + queryset = queryset.filter(farm__owner=owner) + return queryset.first() + + +def get_device_catalog_for_farm_device(farm_device, *, device_code=None): + if farm_device is None: + return None + if device_code: + return farm_device.get_device_catalog_by_code(device_code) + return farm_device.sensor_catalog if farm_device.sensor_catalog_id else (farm_device.get_device_catalogs()[0] if farm_device.get_device_catalogs() else None) + + +def get_latest_device_log(farm_device, *, device_catalog=None): + if farm_device is None: + return None + return get_latest_sensor_external_request_log( + farm_uuid=farm_device.farm.farm_uuid, + sensor_catalog_uuid=device_catalog.uuid if device_catalog else (farm_device.sensor_catalog.uuid if farm_device.sensor_catalog else None), + physical_device_uuid=farm_device.physical_device_uuid, + ) + + +def get_device_logs(farm_device, *, range_value=None, date_from=None, date_to=None): + if farm_device is None: + return SensorExternalRequestLog.objects.none() + if range_value: + date_from = timezone.localdate() - timedelta(days=max(range_value - 1, 0)) + return get_sensor_external_request_logs_for_farm( + farm_uuid=farm_device.farm.farm_uuid, + physical_device_uuid=farm_device.physical_device_uuid, + date_from=date_from, + date_to=date_to, + ) + + +def validate_output_device_catalog(*, farm_device, device_code): + device_catalog = get_device_catalog_for_farm_device(farm_device, device_code=device_code) + if device_catalog is None: + raise ValueError("Device code is not attached to this farm device.") + if device_catalog.device_communication_type == "input_only": + raise ValueError("Selected device code is input-only and cannot be used for output data endpoints.") + return device_catalog + + +def _get_default_field_definition_map(): + return {field["id"]: field for field in SENSOR_FIELDS} + + +def _normalize_payload_keys(payload_keys): + if isinstance(payload_keys, str): + return [payload_keys] + if isinstance(payload_keys, (list, tuple)): + return [item for item in payload_keys if isinstance(item, str) and item] + return [] + + +def _get_device_field_definitions(device_catalog): + default_field_map = _get_default_field_definition_map() + if device_catalog is None: + return list(default_field_map.values()) + + payload_mapping = device_catalog.payload_mapping if isinstance(device_catalog.payload_mapping, dict) else {} + display_schema = device_catalog.display_schema if isinstance(device_catalog.display_schema, dict) else {} + display_fields = display_schema.get("fields", []) if isinstance(display_schema.get("fields", []), list) else [] + + ordered_ids = [] + for item in display_fields: + if isinstance(item, dict) and item.get("id"): + ordered_ids.append(item["id"]) + for item in device_catalog.returned_data_fields: + if isinstance(item, str) and item not in ordered_ids: + ordered_ids.append(item) + for item in payload_mapping.keys(): + if item not in ordered_ids: + ordered_ids.append(item) + if not ordered_ids: + ordered_ids = list(default_field_map.keys()) + + display_field_map = { + item["id"]: item for item in display_fields if isinstance(item, dict) and item.get("id") + } + field_definitions = [] + for field_id in ordered_ids: + default_field = default_field_map.get(field_id, {}) + display_field = display_field_map.get(field_id, {}) + payload_keys = _normalize_payload_keys(payload_mapping.get(field_id)) or list(default_field.get("payload_keys", [])) or [field_id] + field_definitions.append( + { + "id": field_id, + "label": display_field.get("label") or default_field.get("label") or field_id.replace("_", " ").title(), + "unit": display_field.get("unit") or default_field.get("unit") or "", + "payload_keys": payload_keys, + "ideal_min": display_field.get("ideal_min", default_field.get("ideal_min", 0.0)), + "ideal_max": display_field.get("ideal_max", default_field.get("ideal_max", 100.0)), + "radar_label": display_field.get("radar_label") or default_field.get("radar_label") or display_field.get("label") or default_field.get("label") or field_id, + } + ) + return field_definitions + + +def _extract_payload_with_field_definitions(payload, field_definitions): + if not isinstance(payload, dict): + return {} + if isinstance(payload.get("payload"), dict): + payload = payload["payload"] + expected_keys = {key for field in field_definitions for key in field.get("payload_keys", [])} + if isinstance(payload.get("data"), dict): + nested = payload["data"] + if not expected_keys or any(key in nested for key in expected_keys): + payload = nested + return payload + + +def normalize_device_payload(device_catalog, payload): + field_definitions = _get_device_field_definitions(device_catalog) + payload = _extract_payload_with_field_definitions(payload, field_definitions) + normalized_payload = {} + for field in field_definitions: + for key in field["payload_keys"]: + if key in payload: + normalized_payload[field["id"]] = payload[key] + break + return normalized_payload + + +def extract_device_readings(device_catalog, payload): + normalized_payload = normalize_device_payload(device_catalog, payload) + readings = {} + for key, value in normalized_payload.items(): + numeric_value = _to_float(value) + if numeric_value is not None: + readings[key] = numeric_value + return readings + + +def _get_device_supported_widgets(device_catalog): + if device_catalog is None: + return list(DEFAULT_DEVICE_WIDGETS) + widgets = device_catalog.supported_widgets if isinstance(device_catalog.supported_widgets, list) else [] + if widgets: + return widgets + if device_catalog.device_communication_type == "input_only": + return [] + return list(DEFAULT_DEVICE_WIDGETS) + + +def _get_device_history_context(farm_device): + if farm_device is None: + raise DeviceDataUnavailableError( + error_code="device_not_found", + message="Farm device instance is required for history lookup.", + ) + try: + logs_queryset = get_device_logs(farm_device) + except ValueError as exc: + logger.error( + "Device history lookup failed for farm_device_id=%s: %s", + getattr(farm_device, "id", None), + exc, + ) + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device history lookup failed for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + retriable=True, + ) from exc + history = [] + device_catalog = get_device_catalog_for_farm_device(farm_device) + for log in logs_queryset[:MAX_HISTORY_ITEMS]: + readings = extract_device_readings(device_catalog, log.payload) + normalized_payload = normalize_device_payload(device_catalog, log.payload) + if readings or normalized_payload: + history.append((log, readings, normalized_payload)) + if not history: + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device history found for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + latest_log, latest_readings, latest_payload = history[0] + return { + "farm_device": farm_device, + "latest_log": latest_log, + "latest_readings": latest_readings, + "latest_payload": latest_payload, + "previous_readings": history[1][1] if len(history) > 1 else {}, + "history": history, + } + + +def build_device_meta(farm_device, context=None, *, device_catalog=None): + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + latest_log = (context or {}).get("latest_log") + return { + "name": farm_device.name if farm_device else "", + "physicalDeviceUuid": str(farm_device.physical_device_uuid) if farm_device else None, + "sensorCatalogCode": device_catalog.code if device_catalog else "", + "updatedAt": latest_log.created_at.isoformat() if latest_log else None, + } + + +def build_device_latest_payload(farm_device, *, device_code): + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + latest_log = get_latest_device_log(farm_device, device_catalog=device_catalog) + if latest_log is None: + raise DeviceDataUnavailableError( + error_code="no_device_payload", + message=f"No device payload log found for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return { + "physical_device_uuid": farm_device.physical_device_uuid, + "device_code": device_code, + "device_catalog_code": device_catalog.code if device_catalog else None, + "raw_payload": latest_log.payload, + "normalized_payload": normalize_device_payload(device_catalog, latest_log.payload), + "readings": extract_device_readings(device_catalog, latest_log.payload), + "created_at": latest_log.created_at, + } + + +def build_device_values_list(farm_device, range_value, *, device_code): + try: + logs_queryset = get_device_logs(farm_device) + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device values list data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + retriable=True, + ) from exc + start_time = timezone.now() - VALUES_LIST_RANGES[range_value] + logs = list(logs_queryset.filter(created_at__gte=start_time).order_by("created_at", "id")) + if not logs: + latest_log = logs_queryset.order_by("-created_at", "-id").first() + if latest_log is None: + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device logs found for values list farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + logs = [latest_log] + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + earliest_payload = {} + latest_payload = {} + for log in logs: + normalized_payload = extract_device_readings(device_catalog, log.payload) + if not normalized_payload: + continue + if not earliest_payload: + earliest_payload = normalized_payload + latest_payload = normalized_payload + if not latest_payload: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"Latest device payload has no numeric values for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + sensors = [] + for field in _get_device_field_definitions(device_catalog): + current_value = latest_payload.get(field["id"]) + if current_value is None: + continue + previous_value = earliest_payload.get(field["id"], current_value) + delta = round(current_value - previous_value, 2) + sensors.append( + { + "title": field["label"], + "subtitle": _format_current_value_subtitle(field["label"], current_value, field["unit"]), + "trendNumber": abs(delta), + "trend": "positive" if delta >= 0 else "negative", + "unit": field["unit"], + } + ) + if not sensors: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No device values could be derived for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return {"sensors": sensors, "status": "success", "source": "db"} + + +def build_device_summary_values_list(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + data = {"sensor": build_device_meta(farm_device, context), "sensors": []} + latest_readings = context.get("latest_readings", {}) if context else {} + previous_readings = context.get("previous_readings", {}) if context else {} + for field in _get_device_field_definitions(device_catalog): + value = latest_readings.get(field["id"]) + if value is None: + continue + previous = previous_readings.get(field["id"]) + change = 0.0 if previous is None else round(value - previous, 2) + data["sensors"].append( + { + "id": field["id"], + "title": _format_value(value, field["unit"]), + "subtitle": field["label"], + "trendNumber": abs(change), + "trend": "positive" if change >= 0 else "negative", + "unit": field["unit"], + } + ) + if not data["sensors"]: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No summary values available for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + return data + + +def build_device_radar_chart(farm_device, range_value=None, *, device_code): + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + context = _get_device_history_context(farm_device) + if not context or not context.get("latest_readings"): + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"Device radar chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + labels, current_data, ideal_data = [], [], [] + for field in _get_device_field_definitions(device_catalog): + current_value = context["latest_readings"].get(field["id"]) + if current_value is None: + continue + labels.append(field["radar_label"]) + current_data.append(round(current_value, 2)) + midpoint = (field["ideal_min"] + field["ideal_max"]) / 2 + ideal_data.append(round(midpoint, 2)) + if not labels: + raise DeviceDataUnavailableError( + error_code="no_radar_data", + message=f"No usable readings found for radar chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return {"labels": labels, "series": [{"name": "وضعیت فعلی", "data": current_data}, {"name": "بازه ایده آل", "data": ideal_data}], "status": "success", "source": "db"} + + +def build_device_comparison_chart(farm_device, range_value, *, device_code): + days = COMPARISON_CHART_RANGES[range_value] + start_date = timezone.localdate() - timedelta(days=days - 1) + try: + logs_queryset = get_device_logs(farm_device, date_from=start_date) + except ValueError as exc: + raise DeviceDataUnavailableError( + error_code="history_unavailable", + message=f"Device comparison chart data is unavailable for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + retriable=True, + ) from exc + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + field_definitions = _get_device_field_definitions(device_catalog) + grouped_logs = {} + for log in reversed(list(logs_queryset[: days * 24])): + bucket_date = timezone.localtime(log.created_at).date() + grouped_logs[bucket_date] = extract_device_readings(device_catalog, log.payload) + if not grouped_logs: + raise DeviceDataUnavailableError( + error_code="no_device_history", + message=f"No device history found for comparison chart farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + sorted_dates = sorted(grouped_logs.keys()) + categories = [_format_comparison_category(bucket_date, range_value) for bucket_date in sorted_dates] + series = [] + primary_data = [] + for field in field_definitions: + data_points = [] + for bucket_date in sorted_dates: + value = grouped_logs[bucket_date].get(field["id"]) + if value is None: + data_points.append(0.0) + else: + data_points.append(round(value, 2)) + if any(point != 0.0 for point in data_points): + series.append({"name": field["label"], "data": data_points}) + if not primary_data: + primary_data = data_points + if not series or not primary_data: + raise DeviceDataUnavailableError( + error_code="no_comparison_data", + message=f"Device comparison chart has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)} device_code={device_code}.", + details={"farm_device_id": getattr(farm_device, "id", None), "device_code": device_code}, + ) + return { + "series": series, + "categories": categories, + "currentValue": round(primary_data[-1], 2), + "vsLastWeek": _format_percent_change(primary_data[-1], primary_data[0]), + "status": "success", + "source": "db", + } + + +def build_device_anomaly_detection_card(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + anomalies = [] + latest_readings = context.get("latest_readings", {}) if context else {} + for field in _get_device_field_definitions(device_catalog): + value = latest_readings.get(field["id"]) + if value is None: + continue + anomaly = _build_anomaly_item(field, value) + if anomaly is not None: + anomalies.append(anomaly) + if not latest_readings: + raise DeviceDataUnavailableError( + error_code="no_numeric_readings", + message=f"No latest readings available for anomaly detection farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + return { + "anomalies": anomalies, + "status": "success", + "source": "db", + "warnings": [] if anomalies else ["No anomalies detected from the latest device readings."], + } + + +def build_device_soil_moisture_heatmap(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + if not context or not context.get("history"): + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"Device heatmap data is unavailable for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + field_definitions = _get_device_field_definitions(device_catalog) + primary_field = field_definitions[0] if field_definitions else None + if primary_field is None: + raise DeviceDataUnavailableError( + error_code="invalid_schema", + message=f"Device field schema is missing for heatmap farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + chart_points = [] + for log, readings, _normalized_payload in reversed(context["history"][:MAX_CHART_POINTS]): + value = readings.get(primary_field["id"]) + if value is None: + continue + chart_points.append({"x": log.created_at.strftime("%H:%M"), "y": round(value, 2)}) + if not chart_points: + raise DeviceDataUnavailableError( + error_code="no_heatmap_data", + message=f"Device heatmap has no usable numeric series for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + sensor_name = farm_device.name if farm_device and farm_device.name else "Sensor" + return { + "zones": [sensor_name], + "hours": [point["x"] for point in chart_points], + "series": [{"name": sensor_name, "data": chart_points}], + "status": "success", + "source": "db", + } + + +def build_device_avg_primary_metric(farm_device, context=None, *, device_catalog=None): + context = _get_device_history_context(farm_device) if context is None else context + latest_readings = context.get("latest_readings", {}) if context else {} + device_catalog = device_catalog or get_device_catalog_for_farm_device(farm_device) + field_definitions = _get_device_field_definitions(device_catalog) + primary_field = field_definitions[0] if field_definitions else None + if primary_field is None: + raise DeviceDataUnavailableError( + error_code="invalid_schema", + message=f"Device field schema is missing for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + primary_value = latest_readings.get(primary_field["id"]) + if primary_value is None: + raise DeviceDataUnavailableError( + error_code="missing_primary_metric", + message=f"Primary metric is missing for farm_device_id={getattr(farm_device, 'id', None)}.", + details={"farm_device_id": getattr(farm_device, "id", None)}, + ) + chip_text, chip_color, avatar_color = _calculate_status_chip(primary_value) + return { + **deepcopy(AVG_SOIL_MOISTURE_TEMPLATE), + "title": primary_field["label"], + "stats": _format_value(primary_value, primary_field["unit"]), + "chipText": chip_text, + "chipColor": chip_color, + "avatarColor": avatar_color, + "status": "success", + "source": "db", + } + + +def build_device_summary(farm_device, *, device_code): + context = _get_device_history_context(farm_device) + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + summary = {"sensor": build_device_meta(farm_device, context, device_catalog=device_catalog), "supportedWidgets": _get_device_supported_widgets(device_catalog)} + if device_catalog and device_catalog.device_communication_type == "input_only": + summary["commands"] = device_catalog.commands_schema if isinstance(device_catalog.commands_schema, list) else [] + return summary + summary["sensorValuesList"] = build_device_summary_values_list(farm_device, context=context, device_catalog=device_catalog) + if "comparison_chart" in summary["supportedWidgets"]: + summary["sensorComparisonChart"] = build_device_comparison_chart(farm_device, "7d", device_code=device_code) + if "radar_chart" in summary["supportedWidgets"]: + summary["sensorRadarChart"] = build_device_radar_chart(farm_device, device_code=device_code) + if "anomaly_card" in summary["supportedWidgets"]: + summary["anomalyDetectionCard"] = build_device_anomaly_detection_card(farm_device, context=context, device_catalog=device_catalog) + if "soil_moisture_heatmap" in summary["supportedWidgets"]: + summary["soilMoistureHeatmap"] = build_device_soil_moisture_heatmap(farm_device, context=context, device_catalog=device_catalog) + summary["avgSoilMoisture"] = build_device_avg_primary_metric(farm_device, context=context, device_catalog=device_catalog) + return summary + + +def validate_device_command(farm_device, command, payload, *, device_code): + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + commands_schema = device_catalog.commands_schema if device_catalog and isinstance(device_catalog.commands_schema, list) else [] + if not commands_schema: + raise ValueError("This device does not support commands.") + matched_command = next( + (item for item in commands_schema if isinstance(item, dict) and item.get("command") == command), + None, + ) + if matched_command is None: + raise ValueError("Command is not supported for this device.") + payload = payload or {} + if not isinstance(payload, dict): + raise ValueError("`payload` must be an object.") + payload_schema = matched_command.get("payload_schema", {}) + if not isinstance(payload_schema, dict): + return matched_command + for key, expected_type in payload_schema.items(): + if key not in payload: + raise ValueError(f"`{key}` is required for this command.") + expected_python_type = DEVICE_COMMAND_PAYLOAD_TYPES.get(expected_type) + if expected_python_type is None: + continue + if expected_type == "integer" and isinstance(payload[key], bool): + raise ValueError(f"`{key}` must be of type {expected_type}.") + if not isinstance(payload[key], expected_python_type): + raise ValueError(f"`{key}` must be of type {expected_type}.") + return matched_command + + +def execute_device_command(*, farm_device, device_code, command, payload=None): + validate_device_command(farm_device, command, payload or {}, device_code=device_code) + return { + "physical_device_uuid": farm_device.physical_device_uuid, + "device_code": device_code, + "command": command, + "status": "queued", + } diff --git a/Modules/Backend/device_hub/templates.py b/Modules/Backend/device_hub/templates.py new file mode 100644 index 0000000..f3bf20e --- /dev/null +++ b/Modules/Backend/device_hub/templates.py @@ -0,0 +1,23 @@ +AVG_SOIL_MOISTURE_TEMPLATE = { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-droplet", + "chipText": "بدون داده", + "chipColor": "secondary", +} + +SENSOR_META_TEMPLATE = { + "name": "سنسور 7 در 1 خاک", + "physicalDeviceUuid": None, + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": None, +} + +SOIL_MOISTURE_HEATMAP_TEMPLATE = { + "zones": [], + "hours": [], + "series": [], +} diff --git a/Modules/Backend/device_hub/tests.py b/Modules/Backend/device_hub/tests.py new file mode 100644 index 0000000..78d13a0 --- /dev/null +++ b/Modules/Backend/device_hub/tests.py @@ -0,0 +1,170 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from farm_hub.models import FarmHub, FarmType + +from .models import DeviceCatalog, SensorExternalRequestLog +from .services import DeviceDataUnavailableError, build_device_anomaly_detection_card +from .views import DeviceCodeListView, DeviceCommandView, DeviceDetailView, DeviceLatestPayloadView, DeviceSummaryView + + +class DeviceHubGenericViewsTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="device-user", + password="secret123", + email="device@example.com", + phone_number="09120001000", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Device Farm", + ) + self.catalog = DeviceCatalog.objects.create( + code="soil_sensor_v2", + name="Soil Sensor V2", + device_communication_type=DeviceCatalog.OUTPUT_ONLY, + returned_data_fields=["soil_moisture", "soil_temperature"], + payload_mapping={ + "soil_moisture": ["moisture", "soil_moisture"], + "soil_temperature": ["temperature", "soil_temperature"], + }, + display_schema={ + "fields": [ + {"id": "soil_moisture", "label": "رطوبت خاک", "unit": "%", "ideal_min": 40, "ideal_max": 70}, + {"id": "soil_temperature", "label": "دمای خاک", "unit": "°C", "ideal_min": 18, "ideal_max": 30}, + ] + }, + supported_widgets=["values_list", "comparison_chart", "radar_chart"], + ) + self.device = self.farm.sensors.create( + name="Soil Device 1", + sensor_catalog=self.catalog, + sensor_type="soil", + ) + SensorExternalRequestLog.objects.create( + farm_uuid=self.farm.farm_uuid, + sensor_catalog_uuid=self.catalog.uuid, + physical_device_uuid=self.device.physical_device_uuid, + payload={"moisture": 52.4, "temperature": 23.1}, + ) + + def test_device_detail_view_returns_generic_payload(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceDetailView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["physical_device_uuid"], str(self.device.physical_device_uuid)) + self.assertEqual(response.data["data"]["device_catalog"]["code"], self.catalog.code) + + def test_device_code_list_view_returns_attached_device_codes(self): + secondary_catalog = DeviceCatalog.objects.create( + code="air_sensor_v1", + name="Air Sensor V1", + device_communication_type=DeviceCatalog.OUTPUT_ONLY, + ) + self.device.device_catalogs.add(self.catalog, secondary_catalog) + + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/device-codes/", + ) + force_authenticate(request, user=self.user) + + response = DeviceCodeListView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["physical_device_uuid"], str(self.device.physical_device_uuid)) + self.assertEqual(response.data["data"]["device_codes"], [self.catalog.code, secondary_catalog.code]) + + def test_device_code_list_view_returns_primary_catalog_when_no_m2m_catalogs_exist(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/device-codes/", + ) + force_authenticate(request, user=self.user) + + response = DeviceCodeListView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["device_codes"], [self.catalog.code]) + + def test_device_latest_payload_view_returns_normalized_readings(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/latest/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceLatestPayloadView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["normalized_payload"]["soil_moisture"], 52.4) + self.assertEqual(response.data["data"]["readings"]["soil_temperature"], 23.1) + + def test_device_summary_view_returns_supported_widgets(self): + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 200) + self.assertIn("values_list", response.data["data"]["supportedWidgets"]) + self.assertIn("sensorValuesList", response.data["data"]) + + def test_device_summary_view_returns_validation_error_when_history_missing(self): + SensorExternalRequestLog.objects.all().delete() + request = self.factory.get( + f"/api/device-hub/devices/{self.device.physical_device_uuid}/summary/", + {"device_code": self.catalog.code}, + ) + force_authenticate(request, user=self.user) + + response = DeviceSummaryView.as_view()(request, physical_device_uuid=self.device.physical_device_uuid) + + self.assertEqual(response.status_code, 400) + self.assertIn("no device history found", response.data["device_code"][0].lower()) + + def test_build_device_anomaly_detection_card_returns_explicit_empty_success(self): + payload = build_device_anomaly_detection_card(self.device) + + self.assertEqual(payload["status"], "success") + self.assertEqual(payload["source"], "db") + self.assertEqual(payload["anomalies"], []) + self.assertTrue(payload["warnings"]) + + def test_input_only_device_command_view_rejects_input_only_device_code(self): + input_catalog = DeviceCatalog.objects.create( + code="valve_v1", + name="Valve V1", + device_communication_type=DeviceCatalog.INPUT_ONLY, + commands_schema=[ + {"command": "open", "label": "Open", "payload_schema": {"duration_seconds": "integer"}}, + ], + ) + input_device = self.farm.sensors.create( + name="Valve 1", + sensor_catalog=input_catalog, + sensor_type="valve", + ) + request = self.factory.post( + f"/api/device-hub/devices/{input_device.physical_device_uuid}/commands/", + {"device_code": input_catalog.code, "command": "open", "payload": {"duration_seconds": 120}}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = DeviceCommandView.as_view()(request, physical_device_uuid=input_device.physical_device_uuid) + + self.assertEqual(response.status_code, 400) + self.assertIn("device_code", response.data) diff --git a/Modules/Backend/device_hub/urls.py b/Modules/Backend/device_hub/urls.py new file mode 100644 index 0000000..8d98c4d --- /dev/null +++ b/Modules/Backend/device_hub/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import DeviceCatalogListView, DeviceCodeListView, DeviceCommandView, DeviceComparisonChartView, DeviceDetailView, DeviceLatestPayloadView, DeviceLogListView, DeviceRadarChartView, DeviceSummaryView, DeviceValuesListView, Sensor7In1SummaryView, SensorExternalAPIView, SensorExternalRequestLogListAPIView + +urlpatterns = [ + path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"), + path("devices//device-codes/", DeviceCodeListView.as_view(), name="device-code-list"), + path("devices//", DeviceDetailView.as_view(), name="device-detail"), + path("devices//latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"), + path("devices//summary/", DeviceSummaryView.as_view(), name="device-summary"), + path("devices//values-list/", DeviceValuesListView.as_view(), name="device-values-list"), + path("devices//comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"), + path("devices//radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), + path("devices//logs/", DeviceLogListView.as_view(), name="device-log-list"), + path("devices//commands/", DeviceCommandView.as_view(), name="device-command"), + path("summary/", Sensor7In1SummaryView.as_view(), name="sensor-7-in-1-summary"), + path("", DeviceCatalogListView.as_view(), name="sensor-catalog-list"), + path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), + path("external/logs/", SensorExternalRequestLogListAPIView.as_view(), name="sensor-external-api-log-list"), +] diff --git a/Modules/Backend/device_hub/views.py b/Modules/Backend/device_hub/views.py new file mode 100644 index 0000000..f2e7bc0 --- /dev/null +++ b/Modules/Backend/device_hub/views.py @@ -0,0 +1,329 @@ +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiTypes, extend_schema + +from config.swagger import code_response, farm_uuid_query_param +from farm_hub.models import FarmHub +from notifications.serializers import FarmNotificationSerializer +from soil.serializers import SoilComparisonChartSerializer, SoilRadarChartSerializer + +from .authentication import SensorExternalAPIKeyAuthentication +from .sensor_serializers import DeviceSummarySerializer, Sensor7In1SummarySerializer, SensorComparisonChartQuerySerializer, SensorComparisonChartResponseSerializer, SensorRadarChartQuerySerializer, SensorRadarChartResponseSerializer, SensorValuesListQuerySerializer, SensorValuesListResponseSerializer +from .serializers import DeviceCatalogSerializer, DeviceCodeListResponseSerializer, DeviceCodeQuerySerializer, DeviceCommandRequestSerializer, DeviceCommandResponseSerializer, DeviceDetailSerializer, DeviceLatestPayloadSerializer, DeviceRangeQuerySerializer, SensorExternalRequestLogQuerySerializer, SensorExternalRequestLogSerializer, SensorExternalRequestSerializer +from .services import DeviceDataUnavailableError, FarmDataForwardError, build_device_comparison_chart, build_device_latest_payload, build_device_radar_chart, build_device_summary, build_device_values_list, create_sensor_external_notification, execute_device_command, forward_sensor_payload_to_farm_data, get_farm_device_by_physical_uuid, get_farm_device_map_for_logs, get_primary_soil_sensor, get_sensor_7_in_1_comparison_chart_data, get_sensor_7_in_1_radar_chart_data, get_sensor_7_in_1_summary_data, get_sensor_comparison_chart_data, get_sensor_external_request_logs_for_farm, get_sensor_radar_chart_data, get_sensor_values_list_data, validate_output_device_catalog + + +class DeviceCatalogListView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(tags=["Sensor Catalog"], responses={200: code_response("DeviceCatalogListResponse", data=DeviceCatalogSerializer(many=True))}) + def get(self, request): + from .models import DeviceCatalog + return Response({"code": 200, "msg": "success", "data": DeviceCatalogSerializer(DeviceCatalog.objects.order_by("code"), many=True).data}, status=status.HTTP_200_OK) + + +class DeviceBaseView(APIView): + permission_classes = [IsAuthenticated] + + def get_farm_device(self, request, physical_device_uuid): + farm_device = get_farm_device_by_physical_uuid(physical_device_uuid=physical_device_uuid, owner=request.user) + if farm_device is None: + raise serializers.ValidationError({"physical_device_uuid": ["Device not found."]}) + return farm_device + + def get_device_code(self, request): + serializer = DeviceCodeQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + return serializer.validated_data["device_code"] + + +class DeviceDetailView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceDetailResponse", data=DeviceDetailSerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + latest_payload = build_device_latest_payload(farm_device, device_code=device_code) + serializer = DeviceDetailSerializer(farm_device, context={"latest_log": type("LatestLog", (), {"created_at": latest_payload["created_at"]})() if latest_payload["created_at"] else None}) + return Response({"code": 200, "msg": "success", "data": serializer.data}, status=status.HTTP_200_OK) + + +class DeviceCodeListView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], responses={200: code_response("DeviceCodeListResponse", data=DeviceCodeListResponseSerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_codes = [catalog.code for catalog in farm_device.get_device_catalogs() if getattr(catalog, "code", "")] + return Response( + { + "code": 200, + "msg": "success", + "data": { + "physical_device_uuid": farm_device.physical_device_uuid, + "device_codes": device_codes, + }, + }, + status=status.HTTP_200_OK, + ) + + +class DeviceLatestPayloadView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLatestPayloadResponse", data=DeviceLatestPayloadSerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + try: + data = build_device_latest_payload(farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class DeviceSummaryView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceSummaryResponse", data=DeviceSummarySerializer())}) + def get(self, request, physical_device_uuid): + farm_device = self.get_farm_device(request, physical_device_uuid) + device_code = self.get_device_code(request) + try: + data = build_device_summary(farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class DeviceComparisonChartView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_comparison_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class DeviceValuesListView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_values_list(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class DeviceRadarChartView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer}) + def get(self, request, physical_device_uuid): + serializer = DeviceRangeQuerySerializer(data={"device_code": request.query_params.get("device_code"), "range": request.query_params.get("range", "7d")}) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + data = build_device_radar_chart(farm_device, serializer.validated_data["range"], device_code=serializer.validated_data["device_code"]) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class SensorExternalRequestLogPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 100 + + +class DeviceLogListView(DeviceBaseView): + pagination_class = SensorExternalRequestLogPagination + + @extend_schema(tags=["Device Hub"], parameters=[OpenApiParameter(name="device_code", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True)], responses={200: code_response("DeviceLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)})}) + def get(self, request, physical_device_uuid): + page = request.query_params.get("page", 1) + page_size = request.query_params.get("page_size", 20) + device_code = request.query_params.get("device_code") + serializer = SensorExternalRequestLogQuerySerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "page": page, + "page_size": page_size, + "physical_device_uuid": physical_device_uuid, + } + ) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + device_catalog = validate_output_device_catalog(farm_device=farm_device, device_code=device_code) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + queryset = get_sensor_external_request_logs_for_farm( + farm_uuid=farm_device.farm.farm_uuid, + physical_device_uuid=farm_device.physical_device_uuid, + ) + queryset = queryset.filter(sensor_catalog_uuid=device_catalog.uuid) + paginator = self.pagination_class() + paginator.page_size = serializer.validated_data["page_size"] + page_obj = paginator.paginate_queryset(queryset, request, view=self) + farm_device_map = get_farm_device_map_for_logs(logs=page_obj) + data = SensorExternalRequestLogSerializer(page_obj, many=True, context={"farm_device_map": farm_device_map}).data + return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK) + + +class DeviceCommandView(DeviceBaseView): + @extend_schema(tags=["Device Hub"], request=DeviceCommandRequestSerializer, responses={200: code_response("DeviceCommandResponse", data=DeviceCommandResponseSerializer())}) + def post(self, request, physical_device_uuid): + serializer = DeviceCommandRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + farm_device = self.get_farm_device(request, physical_device_uuid) + try: + device_catalog = farm_device.get_device_catalog_by_code(serializer.validated_data["device_code"]) + if device_catalog is None: + raise ValueError("Device code is not attached to this farm device.") + result = execute_device_command( + farm_device=farm_device, + device_code=serializer.validated_data["device_code"], + command=serializer.validated_data["command"], + payload=serializer.validated_data.get("payload"), + ) + except ValueError as exc: + raise serializers.ValidationError({"device_code": [str(exc)]}) from exc + return Response({"code": 200, "msg": "command accepted", "data": result}, status=status.HTTP_200_OK) + + +class Sensor7In1SummaryView(APIView): + permission_classes = [IsAuthenticated] + required_feature_code = "sensor-7-in-1" + + @staticmethod + def _get_farm(request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + @staticmethod + def _get_primary_sensor(*, farm): + sensor = get_primary_soil_sensor(farm=farm) + if sensor is None: + raise serializers.ValidationError({"farm_uuid": ["No sensor found for this farm."]}) + return sensor + + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 summary.")], responses={200: code_response("Sensor7In1SummaryResponse", data=Sensor7In1SummarySerializer())}) + def get(self, request): + farm = self._get_farm(request) + try: + data = get_sensor_7_in_1_summary_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) + + +class Sensor7In1RadarChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 radar chart.")], responses={200: code_response("Sensor7In1RadarChartResponse", data=SoilRadarChartSerializer())}) + def get(self, request): + farm = self._get_farm(request) + try: + data = get_sensor_7_in_1_radar_chart_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) + + +class Sensor7In1ComparisonChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm for sensor 7 in 1 comparison chart.")], responses={200: code_response("Sensor7In1ComparisonChartResponse", data=SoilComparisonChartSerializer())}) + def get(self, request): + farm = self._get_farm(request) + try: + data = get_sensor_7_in_1_comparison_chart_data(farm) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response({"code": 200, "msg": "OK", "data": data}, status=status.HTTP_200_OK) + + +class SensorComparisonChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Chart range, supported values: 7d, 30d. Defaults to 7d.")], responses={200: SensorComparisonChartResponseSerializer}) + def get(self, request): + serializer = SensorComparisonChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + try: + data = get_sensor_comparison_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class SensorValuesListView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Values list range, supported values: 1h, 24h, 7d. Defaults to 7d.")], responses={200: SensorValuesListResponseSerializer}) + def get(self, request): + serializer = SensorValuesListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + try: + data = get_sensor_values_list_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class SensorRadarChartView(Sensor7In1SummaryView): + @extend_schema(tags=["Sensor 7 in 1"], parameters=[farm_uuid_query_param(required=True, description="UUID of the farm."), OpenApiParameter(name="range", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Radar chart range, supported values: today, 7d, 30d. Defaults to 7d.")], responses={200: SensorRadarChartResponseSerializer}) + def get(self, request): + serializer = SensorRadarChartQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + farm = self._get_farm(request) + sensor = self._get_primary_sensor(farm=farm) + try: + data = get_sensor_radar_chart_data(farm=farm, physical_device_uuid=sensor.physical_device_uuid, range_value=serializer.validated_data["range"]) + except DeviceDataUnavailableError as exc: + raise serializers.ValidationError({"farm_uuid": [str(exc)]}) from exc + return Response(data, status=status.HTTP_200_OK) + + +class SensorExternalAPIView(APIView): + authentication_classes = [SensorExternalAPIKeyAuthentication] + permission_classes = [AllowAny] + + @extend_schema(tags=["Sensor External API"], request=SensorExternalRequestSerializer, examples=[OpenApiExample("Sensor External API Request", value={"uuid": "22222222-2222-2222-2222-222222222222", "payload": {"moisture_percent": 32.5, "temperature_c": 21.3, "ph": 6.7, "ec_ds_m": 1.1, "nitrogen_mg_kg": 42, "phosphorus_mg_kg": 18, "potassium_mg_kg": 210}}, request_only=True)], parameters=[OpenApiParameter(name="X-API-Key", type=OpenApiTypes.STR, location=OpenApiParameter.HEADER, required=True, default="12345", description="API key for sensor external API.")], responses={201: code_response("SensorExternalAPIResponse", data=FarmNotificationSerializer()), 401: code_response("SensorExternalAPIUnauthorizedResponse"), 404: code_response("SensorExternalAPIDeviceNotFoundResponse"), 503: code_response("SensorExternalAPIUnavailableResponse")}) + def post(self, request): + serializer = SensorExternalRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + notification = create_sensor_external_notification(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload")) + forward_sensor_payload_to_farm_data(physical_device_uuid=serializer.validated_data["uuid"], payload=serializer.validated_data.get("payload")) + except ValueError as exc: + if "not migrated" in str(exc): + return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"code": 404, "msg": "Physical device not found."}, status=status.HTTP_404_NOT_FOUND) + except FarmDataForwardError as exc: + return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + return Response({"code": 201, "msg": "success", "data": FarmNotificationSerializer(notification).data}, status=status.HTTP_201_CREATED) + + +class SensorExternalRequestLogListAPIView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = SensorExternalRequestLogPagination + + @extend_schema(tags=["Sensor External API"], parameters=[OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True, default="11111111-1111-1111-1111-111111111111"), OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=True), OpenApiParameter(name="physical_device_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="sensor_type", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), OpenApiParameter(name="date_to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False)], responses={200: code_response("SensorExternalRequestLogListResponse", data=SensorExternalRequestLogSerializer(many=True), extra_fields={"count": serializers.IntegerField(), "next": serializers.CharField(allow_null=True), "previous": serializers.CharField(allow_null=True)}), 401: code_response("SensorExternalRequestLogListUnauthorizedResponse"), 503: code_response("SensorExternalRequestLogListUnavailableResponse")}) + def get(self, request): + serializer = SensorExternalRequestLogQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + try: + queryset = get_sensor_external_request_logs_for_farm(farm_uuid=serializer.validated_data["farm_uuid"], physical_device_uuid=serializer.validated_data.get("physical_device_uuid"), sensor_type=serializer.validated_data.get("sensor_type"), date_from=serializer.validated_data.get("date_from"), date_to=serializer.validated_data.get("date_to")) + except ValueError: + return Response({"code": 503, "msg": "Required tables are not ready. Run migrations."}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + paginator = self.pagination_class() + paginator.page_size = serializer.validated_data["page_size"] + page = paginator.paginate_queryset(queryset, request, view=self) + farm_device_map = get_farm_device_map_for_logs(logs=page) + data = SensorExternalRequestLogSerializer(page, many=True, context={"farm_device_map": farm_device_map}).data + return Response({"code": 200, "msg": "success", "count": paginator.page.paginator.count, "next": paginator.get_next_link(), "previous": paginator.get_previous_link(), "data": data}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/docker-compose-prod.yaml b/Modules/Backend/docker-compose-prod.yaml new file mode 100644 index 0000000..d7484a2 --- /dev/null +++ b/Modules/Backend/docker-compose-prod.yaml @@ -0,0 +1,163 @@ +services: + db: + image: mirror-docker.runflare.com/library/mysql:8 + container_name: croplogic-db + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - backend_mysql_data:/var/lib/mysql + ports: + - "3306:3306" + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - crop_network + + redis: + image: mirror-docker.runflare.com/library/redis:7-alpine + container_name: backend-redis + command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"] + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + volumes: + - backend_redis_data:/data + networks: + - crop_network + + accsess: + image: mirror-docker.runflare.com/openpolicyagent/opa:0.67.1-static + container_name: backend-accsess + command: ["run", "--server", "--addr=0.0.0.0:8181", "/policies/authz.rego"] + volumes: + - ../accsess/policies:/policies:ro + restart: unless-stopped + networks: + - crop_network + + + web: + container_name: backend-web + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-production} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost} + DB_HOST: croplogic-db + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0} + QDRANT_HOST: ${QDRANT_HOST:-qdrant} + QDRANT_PORT: ${QDRANT_PORT:-6333} + SKIP_MIGRATE: "0" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + accsess: + condition: service_started + restart: unless-stopped + networks: + - crop_network + + celery: + container_name: backend-celery + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com + command: ["celery", "-A", "config", "worker", "-l", "info"] + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-production} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost} + DB_HOST: croplogic-db + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0} + CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" + SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + accsess: + condition: service_started + restart: unless-stopped + networks: + - crop_network + + celery-beat: + container_name: backend-celery-beat + build: + context: . + dockerfile: Dockerfile.Dev + args: + BASE_IMAGE: mirror-docker.runflare.com/library/python:3.10-slim-bookworm + APT_MIRROR: mirror-linux.runflare.com/debian + APT_SECURITY_MIRROR: mirror-linux.runflare.com/debian-security + PIP_INDEX_URL: https://mirror-pypi.runflare.com/simple + PIP_TRUSTED_HOST: mirror-pypi.runflare.com + command: ["celery", "-A", "config", "beat", "-l", "info"] + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-production} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost} + DB_HOST: croplogic-db + CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0} + CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0} + CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" + SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + accsess: + condition: service_started + restart: unless-stopped + networks: + - crop_network + +networks: + crop_network: + external: true + +volumes: + backend_mysql_data: + backend_redis_data: diff --git a/Modules/Backend/docker-compose.yaml b/Modules/Backend/docker-compose.yaml new file mode 100644 index 0000000..14dfd87 --- /dev/null +++ b/Modules/Backend/docker-compose.yaml @@ -0,0 +1,166 @@ +services: + db: + image: docker.iranserver.com/mysql:8 + container_name: croplogic-db + environment: + MYSQL_DATABASE: ${DB_NAME:-croplogic} + MYSQL_USER: ${DB_USER:-croplogic} + MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root} + volumes: + - backend_mysql_data:/var/lib/mysql + + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-root}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - crop_network + + phpmyadmin: + image: docker-mirror.liara.ir/phpmyadmin:latest + container_name: backend-phpmyadmin + environment: + PMA_HOST: croplogic-db + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8082:80" + depends_on: + db: + condition: service_healthy + networks: + - crop_network + + redis: + image: redis:7-alpine + container_name: backend-redis + command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"] + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + ports: + - "6380:6379" + volumes: + - backend_redis_data:/data + networks: + - crop_network + + web: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: backend-web + command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] + volumes: + - .:/app + - ./logs:/app/logs + ports: + - "8000:8000" + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-develop} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + DB_HOST: croplogic-db + CELERY_BROKER_URL: redis://backend-redis:6379/0 + CELERY_RESULT_BACKEND: redis://backend-redis:6379/0 + QDRANT_HOST: qdrant + QDRANT_PORT: 6333 + SKIP_MIGRATE: "0" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + networks: + - crop_network + + celery: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: backend-celery + command: ["celery", "-A", "config", "worker", "-l", "info"] + volumes: + - .:/app + - ./logs:/app/logs + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-develop} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + DB_HOST: croplogic-db + CELERY_BROKER_URL: redis://backend-redis:6379/0 + CELERY_RESULT_BACKEND: redis://backend-redis:6379/0 + CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" + SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + networks: + - crop_network + + celery-beat: + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net + PIP_INDEX_URL: https://package-mirror.liara.ir/repository/pypi/simple + PIP_EXTRA_INDEX_URL: https://mirror2.chabokan.net/pypi/simple + PYTHON_MIRROR: mirror2.chabokan.net + container_name: backend-celery-beat + command: ["celery", "-A", "config", "beat", "-l", "info"] + volumes: + - .:/app + - ./logs:/app/logs + env_file: + - .env + environment: + DOCKER_VERSION: ${DOCKER_VERSION:-develop} + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web} + AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000} + DB_HOST: croplogic-db + CELERY_BROKER_URL: redis://backend-redis:6379/0 + CELERY_RESULT_BACKEND: redis://backend-redis:6379/0 + CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true" + SKIP_MIGRATE: "1" + ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181 + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + networks: + - crop_network + +volumes: + backend_mysql_data: + backend_redis_data: + backend_qdrant_data: + + +networks: + crop_network: + external: true diff --git a/Modules/Backend/docs/dashboard_api_reference.md b/Modules/Backend/docs/dashboard_api_reference.md new file mode 100644 index 0000000..2a0934d --- /dev/null +++ b/Modules/Backend/docs/dashboard_api_reference.md @@ -0,0 +1,240 @@ +# Farm Dashboard API Reference + +این سند، API های `dashboard` را به‌صورت کامل توضیح می‌دهد و برای هر بخش مشخص می‌کند داده از کجا دریافت می‌شود. + +## Endpoint ها + +### 1) دریافت کارت‌های داشبورد +- **Method:** `GET` +- **Path:** `/api/farm-dashboard/` +- **View:** `dashboard/views.py:118` +- **URL config:** `dashboard/urls.py:5` +- **Query param الزامی:** `farm_uuid` +- **Auth:** `IsAuthenticated` + +### 2) دریافت تنظیمات داشبورد +- **Method:** `GET` +- **Path:** `/api/farm-dashboard-config/` +- **View:** `dashboard/views.py:67` +- **URL config:** `dashboard/urls_config.py:5` +- **Query param الزامی:** `farm_uuid` +- **Auth:** `IsAuthenticated` + +### 3) ویرایش تنظیمات داشبورد +- **Method:** `PATCH` +- **Path:** `/api/farm-dashboard-config/` +- **View:** `dashboard/views.py:67` +- **Body:** `farm_uuid` + هرکدام از `disabled_card_ids`، `row_order`، `enable_drag_reorder` +- **Auth:** `IsAuthenticated` + +## نحوه شناسایی مزرعه +- مزرعه از طریق `farm_uuid` و مالک کاربر لاگین‌شده پیدا می‌شود. +- پیاده‌سازی در `dashboard/views.py:20` و `dashboard/views.py:22` است. +- اگر `farm_uuid` ارسال نشود یا مزرعه برای آن کاربر پیدا نشود، خطای validation برمی‌گردد. + +## تنظیمات داشبورد +تنظیمات داشبورد per-farm در دیتابیس ذخیره می‌شود. + +### فیلدها +- `disabled_card_ids`: لیست کارت‌های غیرفعال +- `row_order`: ترتیب ردیف‌ها +- `enable_drag_reorder`: فعال/غیرفعال بودن drag reorder + +### مدل ذخیره‌سازی +- مدل: `FarmDashboardConfig` +- فایل: `dashboard/models.py:6` +- جدول: `farm_dashboard_configs` + +### مقادیر پیش‌فرض +- از `dashboard/defaults.py:4` و `dashboard/defaults.py:30` می‌آید. +- کارت‌های معتبر در `dashboard/defaults.py:16` +- ردیف‌های معتبر در `dashboard/defaults.py:4` + +### اعتبارسنجی +- serializer اصلی: `dashboard/serializers.py:6` +- serializer patch: `dashboard/serializers.py:43` +- `disabled_card_ids` فقط باید از `VALID_CARD_IDS` باشد. +- `row_order` باید تمام `VALID_ROW_IDS` را دقیقاً یک‌بار داشته باشد. + +## نقطه تجمیع اصلی داده‌ها +تمام کارت‌ها در تابع زیر assemble می‌شوند: +- `dashboard/services.py:85` + +این تابع خروجی چند app مختلف را جمع می‌کند و response نهایی dashboard را می‌سازد. + +## منبع داده هر کارت + +### `farmOverviewKpis` +- **از کجا ساخته می‌شود:** `dashboard/services.py:41` +- **نوع:** aggregator +- **منابع ورودی:** + - `farmHealthScore` از `crop_health.services.get_crop_health_summary_data` + - `waterStressIndex` از `water.services.get_water_stress_index_data` + - `avgSoilMoisture` از `device_hub.services.get_sensor_7_in_1_summary_data` + - `disease_risk` و `pest_risk` از `pest_detection.services.get_risk_summary_data` + - `yield_prediction_card` از `yield_harvest.services.get_yield_harvest_summary_data` +- **نکته مهم:** این کارت جدول یا مدل مستقل ندارد؛ از چند سرویس ترکیب می‌شود. + +### `farmWeatherCard` +- **از کجا پر می‌شود:** `water/services.py:9` +- **مدل اصلی:** `WeatherForecastLog` +- **فایل مدل:** `water/models.py:8` +- **جدول:** `weather_forecast_logs` +- **منطق:** جدیدترین رکورد هواشناسی برای همان `farm` خوانده می‌شود. + +### `farmAlertsTracker` +- **از کجا پر می‌شود:** `farm_alerts/services.py:379` +- **مدل اصلی:** `FarmAlert` +- **فایل مدل:** `farm_alerts/models.py:16` +- **جدول:** `farm_alerts` +- **منطق:** هشدارهای active مزرعه خوانده می‌شوند و از روی آن‌ها summary ساخته می‌شود. +- **نکته:** با اینکه مدل `FarmAlertTrackerSnapshot` هم وجود دارد در `farm_alerts/models.py:76`، endpoint فعلی کارت tracker مستقیم از `FarmAlert` می‌سازد، نه از snapshot. + +### `sensorValuesList` +- **از کجا پر می‌شود:** `device_hub/services.py:495` +- **جزء داخلی:** `device_hub/services.py:334` +- **مدل‌ها:** + - `FarmDevice` در `device_hub/models.py:45` + - `SensorExternalRequestLog` در `device_hub/models.py:94` +- **جداول:** + - `farm_sensors` + - `sensor_external_request_logs` +- **منطق:** + - اول سنسور اصلی خاک مزرعه پیدا می‌شود. + - بعد history لاگ‌های همان device خوانده می‌شود. + - از payload لاگ‌ها، مقادیر سنسورها استخراج می‌شود. + +### `sensorRadarChart` +- **از کجا پر می‌شود:** `device_hub/services.py:389` +- **منبع داده:** همان `SensorExternalRequestLog` و `FarmDevice` +- **منطق:** آخرین reading سنسور 7-in-1 گرفته می‌شود و بر اساس ideal range برای هر فیلد score ساخته می‌شود. + +### `sensorComparisonChart` +- **از کجا پر می‌شود:** `device_hub/services.py:412` +- **منبع داده:** `SensorExternalRequestLog` +- **منطق:** history چند reading آخر برای رطوبت خاک گرفته می‌شود و series نمودار ساخته می‌شود. + +### `anomalyDetectionCard` +- **از کجا پر می‌شود:** `device_hub/services.py:451` +- **منبع داده:** `SensorExternalRequestLog` +- **منطق:** آخرین reading با بازه‌های ideal مقایسه می‌شود و anomaly های out-of-range ساخته می‌شود. +- **نکته:** در app `farm_alerts` یک مدل `AnomalyDetection` در `farm_alerts/models.py:41` هم وجود دارد، اما dashboard فعلی این کارت را از آن مدل نمی‌خواند. + +### `farmAlertsTimeline` +- **از کجا پر می‌شود:** `farm_alerts/services.py:410` +- **مدل اصلی:** `FarmAlert` +- **فایل مدل:** `farm_alerts/models.py:16` +- **جدول:** `farm_alerts` +- **منطق:** حداکثر 10 alert آخر مزرعه خوانده می‌شود. + +### `waterNeedPrediction` +- **از کجا پر می‌شود:** `water/services.py:58` +- **مدل اصلی:** `IrrigationRecommendationRequest` +- **فایل مدل:** `irrigation/models.py:9` +- **جدول:** `irrigation_requests` +- **منطق:** + - از `response_payload` آخرین درخواست آبیاری، بخش `water_balance.daily` استخراج می‌شود. + - سپس `gross_irrigation_mm` ها تبدیل به series نمودار می‌شوند. +- **نکته:** این کارت در app `water` assemble می‌شود ولی source واقعی‌اش داده‌ی persisted آبیاری است. + +### `harvestPredictionCard` +- **از کجا پر می‌شود:** `yield_harvest/services.py:7` +- **مدل اصلی:** `YieldHarvestPredictionLog` +- **فایل مدل:** `yield_harvest/models.py:8` +- **جدول:** `yield_harvest_prediction_logs` +- **منطق:** جدیدترین لاگ برداشت/عملکرد مزرعه خوانده می‌شود. + +### `yieldPredictionChart` +- **از کجا پر می‌شود:** `yield_harvest/services.py:7` +- **مدل اصلی:** `YieldHarvestPredictionLog` +- **فایل مدل:** `yield_harvest/models.py:8` +- **جدول:** `yield_harvest_prediction_logs` +- **منطق:** `chart_data` از همان لاگ برداشت/عملکرد برگردانده می‌شود. + +### `soilMoistureHeatmap` +- **از کجا پر می‌شود:** `device_hub/services.py:469` +- **منبع داده:** `SensorExternalRequestLog` +- **مدل کمکی device:** `FarmDevice` +- **منطق:** چند reading آخر رطوبت خاک به فرمت heatmap/chart تبدیل می‌شود. + +### `ndviHealthCard` +- **از کجا پر می‌شود:** `crop_health/services.py:6` +- **منبع داده فعلی:** mock data +- **فایل:** `crop_health/mock_data.py` از طریق `crop_health/services.py:3` +- **منطق:** فعلاً از دیتابیس یا external log خوانده نمی‌شود؛ مستقیم از mock برمی‌گردد. + +### `recommendationsList` +- **از کجا ساخته می‌شود:** `dashboard/services.py:54` +- **نوع:** aggregator +- **منابع ورودی:** + - recommendationهای ذخیره‌شده در `Recommendation` از `farm_alerts/services.py:459` + - پیشنهاد آبیاری از `irrigation/services.py:289` + - پیشنهاد کوددهی از `fertilization/services.py:79` + - بازه برداشت از `yield_harvest.services.get_yield_harvest_summary_data` +- **مدل‌های اصلی:** + - `Recommendation` در `farm_alerts/models.py:59` + - `IrrigationRecommendationRequest` در `irrigation/models.py:9` + - `FertilizationRecommendationRequest` در `fertilization/models.py:9` + - `YieldHarvestPredictionLog` در `yield_harvest/models.py:8` +- **نکته:** این کارت داده چند domain مختلف را یکی می‌کند و duplicate titleها را حذف می‌کند. + +### `economicOverview` +- **از کجا پر می‌شود:** `economic_overview/services.py:7` +- **مدل اصلی:** `EconomicOverviewLog` +- **فایل مدل:** `economic_overview/models.py:8` +- **جدول:** `economic_overview_logs` +- **منطق:** آخرین لاگ اقتصادی مزرعه خوانده می‌شود. + +## منابعی که فعلاً mock هستند +این بخش مهم است، چون user خواسته بداند اطلاعات از کجا می‌آید: + +- `ndviHealthCard` از mock می‌آید: `crop_health/services.py:6` +- `farmHealthScore` که داخل `farmOverviewKpis` استفاده می‌شود هم از mock می‌آید: `crop_health/services.py:6` +- `disease_risk` و `pest_risk` که داخل `farmOverviewKpis` استفاده می‌شوند از mock می‌آیند: `pest_detection/services.py:6` + +## منابعی که از دیتابیس می‌آیند +- تنظیمات dashboard از `FarmDashboardConfig` +- weather از `WeatherForecastLog` +- alerts/timeline از `FarmAlert` +- recommendationهای ذخیره‌شده از `Recommendation` +- داده آبیاری از `IrrigationRecommendationRequest` +- داده کوددهی برای recommendation card از `FertilizationRecommendationRequest` +- برداشت/عملکرد از `YieldHarvestPredictionLog` +- overview اقتصادی از `EconomicOverviewLog` +- سنسورها از `FarmDevice` و `SensorExternalRequestLog` + +## وابستگی بین app ها در dashboard +تجمیع dashboard در `dashboard/services.py:85` به این app ها وابسته است: +- `water` +- `crop_health` +- `economic_overview` +- `farm_alerts` +- `fertilization` +- `irrigation` +- `pest_detection` +- `device_hub` +- `yield_harvest` + +## نمونه flow برای `GET /api/farm-dashboard/` +1. کاربر `farm_uuid` را می‌فرستد. +2. در `dashboard/views.py:127` مزرعه متعلق به user پیدا می‌شود. +3. `dashboard/services.py:85` صدا زده می‌شود. +4. این تابع به سرویس‌های appهای مختلف call می‌زند. +5. هر سرویس یا از DB می‌خواند یا از mock/template. +6. پاسخ نهایی به‌صورت یک object شامل تمام cardها برمی‌گردد. + +## نکات مهم عملی +- endpoint کارت‌ها فقط config را برنمی‌گرداند؛ payload کامل تمام cardها را یکجا برمی‌گرداند. +- config dashboard از خود کارت‌ها جداست و در endpoint جداگانه مدیریت می‌شود. +- بعضی کارت‌ها production data دارند، بعضی transitional هستند، و بعضی هنوز mock دارند. +- اگر برای مزرعه داده‌ای در بعضی جدول‌ها نباشد، معمولاً fallback/template خالی برمی‌گردد. + +## فایل‌های مرجع مهم +- `dashboard/views.py:67` +- `dashboard/views.py:118` +- `dashboard/services.py:85` +- `dashboard/defaults.py:4` +- `dashboard/serializers.py:6` +- `dashboard/models.py:6` +- `docs/dashboard_card_service_map.md:1` + diff --git a/Modules/Backend/docs/dashboard_card_service_map.md b/Modules/Backend/docs/dashboard_card_service_map.md new file mode 100644 index 0000000..4936667 --- /dev/null +++ b/Modules/Backend/docs/dashboard_card_service_map.md @@ -0,0 +1,80 @@ +# نقشه سرویس کارت های داشبورد + +این سند مرجع فشرده `وضعیت واقعی کارت های داشبورد` است؛ نه طراحی آینده. +تمرکز آن روی منبع داده واقعی، status فعلی، و semantics پاسخ در runtime است. + +## قانون runtime در برابر seed + +- داده seed / bootstrap / fixture مجاز است و باید فقط از مسیرهای seeding و bootstrap در دسترس بماند. +- داده `mock/sample/demo` نباید در مسیر runtime سرویس، view یا adapter برای تولید پاسخ production-like استفاده شود. +- اگر داده واقعی وجود ندارد، سرویس باید `empty state` یا `failure contract` صریح برگرداند، نه داده ساختگی موفق. + +## نقطه شروع فعلی + +- تجمیع اصلی کارت‌ها در `dashboard/services.py` داخل `get_farm_dashboard_cards` انجام می‌شود. +- endpoint فعلی ارسال کارت‌ها در `dashboard/views.py` داخل `FarmDashboardCardsView` قرار دارد. +- لیست کارت‌های معتبر در `dashboard/defaults.py` داخل `VALID_CARD_IDS` نگهداری می‌شود. + +## جمع‌بندی سریع + +| Card ID | Status | semantics | منبع اصلی | تابع/سرویس فعلی | app داده | توضیح | +| --- | --- | --- | --- | --- | --- | --- | +| `farmOverviewKpis` | `implemented / transitional` | aggregator | تجمیع چند سرویس | `_build_overview_kpis` | `dashboard` | منبع واحد ندارد | +| `farmWeatherCard` | `partial` | provider/persisted | آب و هوا | `get_farm_weather_card_data` | `water` | نباید fallback ساختگی runtime داشته باشد | +| `farmAlertsTracker` | `implemented` | cached snapshot | snapshot persisted | `get_alert_tracker_data` | `farm_alerts` | live AI نیست | +| `sensorValuesList` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | adoption کامل facade `farm_data` هنوز کامل نشده | +| `sensorRadarChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت | +| `sensorComparisonChart` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | همان وضعیت | +| `anomalyDetectionCard` | `implemented / transitional` | derived from sensor logs | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | ownership نهایی anomalyها هنوز کامل یکدست نشده | +| `farmAlertsTimeline` | `partial` | persisted timeline | هشدارها | `get_alert_timeline_data` | `farm_alerts` | نباید fallback ساختگی runtime داشته باشد | +| `waterNeedPrediction` | `implemented / proxy-derived` | derived from persisted irrigation recommendation | آبیاری | `get_water_need_prediction_data` | `water` | facade در `water` است ولی business source در `irrigation` قرار دارد | +| `harvestPredictionCard` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید | +| `yieldPredictionChart` | `implemented / proxy-derived` | persisted AI-derived | برداشت/عملکرد | `get_yield_harvest_summary_data` | `yield_harvest` | از لاگ persisted می‌آید | +| `soilMoistureHeatmap` | `implemented / transitional` | persisted sensor log | سنسور 7-in-1 | `get_sensor_7_in_1_summary_data` | `sensor_7_in_1` | facade نهایی همه خوانش‌ها را هنوز unify نکرده | +| `ndviHealthCard` | `disabled / partial` | not runtime-ready | سلامت گیاه | `get_crop_health_summary_data` | `crop_health` | نباید به‌عنوان کارت implemented کامل معرفی شود | +| `recommendationsList` | `implemented / transitional` | aggregator | تجمیع پیشنهادها | `_build_recommendations_list` | `dashboard` | از چند app کنار هم ساخته می‌شود | +| `economicOverview` | `implemented` | persisted/log-based | نمای اقتصادی | `get_economic_overview_data` | `economic_overview` | داده اقتصادی persisted | + +## نکات مهم کارت‌ها + +### `farmOverviewKpis` +- aggregator است و باید در `dashboard` بماند. + +### `farmWeatherCard` +- source: `water.models.WeatherForecastLog` +- قرارداد runtime: اگر داده هواشناسی موجود نباشد، باید `empty state` یا `failure contract` صریح برگردد، نه mock. + +### `farmAlertsTracker` +- source: snapshot persisted +- semantics: `cached snapshot` + +### `waterNeedPrediction` +- facade فعلی در `water` +- business source واقعی: `irrigation.models.IrrigationRecommendationRequest` +- semantics: `proxy-derived persisted data` + +### `harvestPredictionCard` و `yieldPredictionChart` +- source: `yield_harvest.models.YieldHarvestPredictionLog` +- semantics: `persisted AI-derived` + +### `ndviHealthCard` +- status: `disabled / partial` +- تا زمانی که source runtime-ready پایدار برای NDVI نهایی نشود، نباید به عنوان کارت production-ready مستند شود. + +## Ownership و transitional boundaries + +- plant catalog canonical در Backend شروع می‌شود. +- dashboard هنوز بعضی کارت‌ها را از facadeهای transitional می‌خواند. +- سنسور / plant / farm ownership به‌تدریج باید به facade `farm_data` نزدیک‌تر شود، ولی همه مصرف‌کننده‌ها هنوز migrate نشده‌اند. + +## Response Semantics + +- `farmAlertsTracker` → `cached snapshot` +- `waterNeedPrediction` → `derived from persisted irrigation recommendation` +- `harvestPredictionCard` / `yieldPredictionChart` → `persisted AI-derived snapshot` +- `farmOverviewKpis` / `recommendationsList` → `dashboard-owned aggregator` + +## Known Gaps / Follow-up + +- ownership نهایی خوانش سنسور بین facade `farm_data` و سرویس‌های legacy هنوز در بعضی کارت‌ها transitional است. +- `ndviHealthCard` هنوز برای runtime production-ready نیست. diff --git a/Modules/Backend/docs/device_catalog_dynamic_architecture.md b/Modules/Backend/docs/device_catalog_dynamic_architecture.md new file mode 100644 index 0000000..3f26430 --- /dev/null +++ b/Modules/Backend/docs/device_catalog_dynamic_architecture.md @@ -0,0 +1,860 @@ +# راهنمای طراحی Device Catalog داینامیک + +## هدف + +هدف این تغییر این است که اضافه کردن یک دیوایس جدید فقط با ثبت اطلاعات در دیتابیس یا پنل ادمین انجام شود و برای هر دیوایس جدید نیازی به اضافه کردن فایل، ویو، serializer یا service جدید در کد نباشد. + +الان ساختار پروژه برای بعضی دیوایس‌ها device-specific است؛ مثلا: + +- `device_hub/sensor_7_in_1_urls.py` +- `Sensor7In1SummaryView` +- `get_sensor_7_in_1_summary_data` +- `get_sensor_7_in_1_radar_chart_data` +- `get_sensor_7_in_1_comparison_chart_data` + +این ساختار برای یک MVP خوب است، ولی برای scale شدن مناسب نیست. چون برای هر دیوایس جدید باید: + +- route جدید بسازید +- view جدید بسازید +- serializer جدید بسازید +- service جدید بسازید +- منطق mapping payload جدید اضافه کنید + +این دقیقا چیزی است که باید حذف شود. + +--- + +## مشکل ساختار فعلی + +الان backend تا حدی بر اساس `device type` یا `sensor-7-in-1` branch می‌زند، نه بر اساس یک configuration عمومی. + +نمونه‌ها: + +- `device_hub/views.py` + - `Sensor7In1SummaryView` + - `Sensor7In1RadarChartView` + - `Sensor7In1ComparisonChartView` +- `device_hub/services.py` + - `get_primary_soil_sensor` + - `get_sensor_7_in_1_summary_data` + - `get_sensor_7_in_1_values_list_data` + - `get_sensor_7_in_1_radar_chart_data` + - `get_sensor_7_in_1_comparison_chart_data` +- `device_hub/sensor_serializers.py` + - `Sensor7In1SummarySerializer` + - `Sensor7In1MetaSerializer` + +مشکل این approach: + +1. اضافه شدن هر device جدید نیاز به deploy کد دارد. +2. naming پروژه به device خاص وابسته می‌شود. +3. APIها generic نیستند. +4. frontend مجبور می‌شود endpointهای مخصوص هر device را صدا بزند. +5. منطق business به‌جای data-driven بودن، hard-coded شده است. + +--- + +## معماری پیشنهادی + +### اصل طراحی + +به‌جای این‌که برای هر device endpoint جدا داشته باشیم، باید فقط یک سری endpoint عمومی داشته باشیم که بر اساس: + +- `physical_device_uuid` +یا +- `device_catalog_uuid` +یا +- `device_catalog.code` + +اطلاعات همان device را برگردانند. + +یعنی backend باید: + +1. device را پیدا کند +2. configuration آن device را از catalog بخواند +3. payload mapping آن device را بخواند +4. widgetهای قابل نمایش آن را تشخیص دهد +5. خروجی استاندارد بسازد + +--- + +## APIهای پیشنهادی + +## راهنمای `device_code` + +در این معماری باید بین این سه مفهوم تفاوت روشن باشد: + +- `physical_device_uuid`: شناسه خودِ دستگاه ثبت‌شده روی مزرعه +- `device_catalog.uuid`: شناسه رکورد catalog +- `device_code`: مقدار متنی فیلد `DeviceCatalog.code` مثل `soil_sensor_v2` یا `irrigation_valve_v1` + +### `device_code` را از کجا می‌گیریم؟ + +دو راه اصلی برای پیدا کردن `device_code`های یک دستگاه وجود دارد: + +#### 1) از جزئیات device + +در پاسخ این endpoint: + +```http +GET /api/device-hub/devices/{physical_device_uuid}/?device_code= +``` + +فیلدهای زیر برمی‌گردند: + +- `data.device_catalog.code` +- `data.device_catalogs[].code` + +یعنی frontend می‌تواند codeهای attachشده به device را از همین پاسخ بخواند. + +#### 2) از endpoint اختصاصی لیست codeها + +```http +GET /api/device-hub/devices/{physical_device_uuid}/device-codes/ +``` + +پاسخ نمونه: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "physical_device_uuid": "device-uuid", + "device_codes": ["soil_sensor_v2", "air_sensor_v1"] + } +} +``` + +این endpoint برای وقتی مناسب است که frontend فقط می‌خواهد بداند این device به چه `device_code`هایی وصل است. + +### `device_code` را کجا باید ارسال کنیم؟ + +`device_code` همیشه لازم نیست. بسته به endpoint یکی از این حالت‌ها را دارد: + +#### الف) در query string + +برای endpointهایی که خروجی آن‌ها باید بر اساس یکی از catalogهای attachشده انتخاب شود: + +```http +GET /api/device-hub/devices/{physical_device_uuid}/?device_code=soil_sensor_v2 +GET /api/device-hub/devices/{physical_device_uuid}/latest/?device_code=soil_sensor_v2 +GET /api/device-hub/devices/{physical_device_uuid}/summary/?device_code=soil_sensor_v2 +GET /api/device-hub/devices/{physical_device_uuid}/values-list/?device_code=soil_sensor_v2&range=7d +GET /api/device-hub/devices/{physical_device_uuid}/comparison-chart/?device_code=soil_sensor_v2&range=7d +GET /api/device-hub/devices/{physical_device_uuid}/radar-chart/?device_code=soil_sensor_v2&range=7d +GET /api/device-hub/devices/{physical_device_uuid}/logs/?device_code=soil_sensor_v2&page=1&page_size=20 +``` + +#### ب) در body درخواست + +برای endpoint command: + +```http +POST /api/device-hub/devices/{physical_device_uuid}/commands/ +``` + +نمونه body: + +```json +{ + "device_code": "irrigation_valve_v1", + "command": "open", + "payload": { + "duration_seconds": 120 + } +} +``` + +#### ج) endpointهایی که اصلاً `device_code` نمی‌خواهند + +این endpoint فقط با `physical_device_uuid` کار می‌کند: + +```http +GET /api/device-hub/devices/{physical_device_uuid}/device-codes/ +``` + +و endpointهای catalog-level هم معمولاً `device_code` لازم ندارند: + +```http +GET /api/device-hub/catalog/ +``` + +### چه زمانی `device_code` اجباری است؟ + +وقتی یک `FarmDevice` ممکن است به چند catalog وصل باشد، backend بدون `device_code` نمی‌تواند بفهمد باید: + +- mapping کدام catalog را اعمال کند +- widgetهای کدام catalog را برگرداند +- لاگ را بر اساس کدام catalog فیلتر کند +- command را برای کدام نوع device validate کند + +پس در endpointهای data/summary/chart/logs/commands باید `device_code` صریح ارسال شود. + +### `device_code` دقیقاً باید چه مقداری باشد؟ + +باید مقدار فیلد `DeviceCatalog.code` ارسال شود، نه: + +- `name` +- `uuid` +- `physical_device_uuid` + +مثال درست: + +```text +soil_sensor_v2 +air_sensor_v1 +irrigation_valve_v1 +``` + +مثال اشتباه: + +```text +Soil Sensor V2 +11111111-1111-1111-1111-111111111111 +22222222-2222-2222-2222-222222222222 +``` + +### اگر `device_code` اشتباه باشد چه می‌شود؟ + +اگر `device_code` به آن device attach نشده باشد، backend باید validation error برگرداند. معمولاً چیزی شبیه این: + +```json +{ + "device_code": [ + "Device code is not attached to this farm device." + ] +} +``` + +### 1) لیست دیوایس‌ها + +```http +GET /api/device-hub/catalog/ +``` + +کاربرد: + +- لیست همه device catalogها +- metadata هر catalog +- نوع ارتباط device +- فیلدهای قابل نمایش + +--- + +### 2) جزئیات یک دیوایس ثبت‌شده روی مزرعه + +```http +GET /api/device-hub/devices/{physical_device_uuid}/?device_code=soil_sensor_v1 +``` + +نکته: + +- در این endpoint، `device_code` باید در query string ارسال شود. +- اگر device فقط یک catalog داشته باشد، از نظر معماری باز هم بهتر است frontend آن را صریح بفرستد. + +پاسخ نمونه: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "uuid": "farm-device-uuid", + "physical_device_uuid": "device-uuid", + "name": "Soil Sensor #1", + "device_catalog": { + "uuid": "catalog-uuid", + "code": "soil_sensor_v1", + "name": "Soil Sensor V1", + "device_communication_type": "output_only" + }, + "specifications": {}, + "power_source": {}, + "last_payload_at": "2025-01-01T10:00:00Z" + } +} +``` + +--- + +### 3) آخرین داده‌ی یک device + +```http +GET /api/device-hub/devices/{physical_device_uuid}/latest/?device_code=soil_sensor_v1 +``` + +کاربرد: + +- آخرین payload خام +- آخرین payload نرمال‌شده +- آخرین readingهای قابل نمایش + +--- + +### 4) summary داینامیک برای یک device + +```http +GET /api/device-hub/devices/{physical_device_uuid}/summary/?device_code=soil_sensor_v1 +``` + +کاربرد: + +- به‌جای `sensor_7_in_1/summary` +- خروجی بر اساس config همان device + +--- + +### 5) نمودار مقایسه‌ای داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/comparison-chart/?device_code=soil_sensor_v1&range=7d +``` + +--- + +### 6) نمودار رادار داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/radar-chart/?device_code=soil_sensor_v1&range=7d +``` + +--- + +### 7) values list داینامیک + +```http +GET /api/device-hub/devices/{physical_device_uuid}/values-list/?device_code=soil_sensor_v1&range=7d +``` + +--- + +### 8) دریافت history خام + +```http +GET /api/device-hub/devices/{physical_device_uuid}/logs/?device_code=soil_sensor_v1&page=1&page_size=20 +``` + +این endpoint برای debug و audit خیلی مهم است. + +--- + +## تغییر مهم در مدل‌ها + +### 1) `DeviceCatalog` + +الان این مدل شروع خوبی دارد، ولی برای dynamic شدن کافی نیست. + +مدل فعلی در: + +- `device_hub/models.py:6` + +فیلدهای پیشنهادی جدید: + +```python +display_schema = models.JSONField(default=dict, blank=True) +payload_mapping = models.JSONField(default=dict, blank=True) +supported_widgets = models.JSONField(default=list, blank=True) +commands_schema = models.JSONField(default=list, blank=True) +capabilities = models.JSONField(default=list, blank=True) +``` + +### توضیح هر فیلد + +#### `payload_mapping` + +مشخص می‌کند payload خام این device چطور به فیلدهای استاندارد سیستم map شود. + +مثال: + +```json +{ + "soil_moisture": ["soil_moisture", "soilMoisture", "moisture"], + "soil_temperature": ["soil_temperature", "soilTemperature", "temperature"], + "soil_ph": ["soil_ph", "soilPh", "ph"] +} +``` + +#### `display_schema` + +مشخص می‌کند کدام فیلدها در UI نمایش داده شوند و label و unit آن‌ها چیست. + +مثال: + +```json +{ + "fields": [ + { + "id": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + "ideal_min": 45, + "ideal_max": 65 + }, + { + "id": "soil_temperature", + "label": "دمای خاک", + "unit": "°C", + "ideal_min": 18, + "ideal_max": 28 + } + ] +} +``` + +#### `supported_widgets` + +مشخص می‌کند برای این device چه widgetهایی فعال باشند. + +مثال: + +```json +[ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload", + "anomaly_card" +] +``` + +#### `commands_schema` + +برای deviceهایی که `input_only` هستند. + +مثال: + +```json +[ + { + "command": "turn_on", + "label": "روشن کردن", + "payload_schema": { + "duration_seconds": "integer" + } + }, + { + "command": "turn_off", + "label": "خاموش کردن", + "payload_schema": {} + } +] +``` + +#### `capabilities` + +فهرست capabilityهای device: + +```json +["measure", "history", "alert", "command"] +``` + +--- + +## برای deviceهای ورودی‌محور + +شما گفتی بعضی deviceها فقط باید دستور بگیرند و خروجی نمی‌دهند. این دقیقا باید در مدل و API مشخص باشد. + +برای این نوع device: + +- `device_communication_type = "input_only"` +- `returned_data_fields = []` +- `supported_widgets = []` +- `commands_schema` باید پر باشد + +API پیشنهادی: + +```http +POST /api/device-hub/devices/{physical_device_uuid}/commands/ +``` + +payload نمونه: + +```json +{ + "device_code": "irrigation_valve_v1", + "command": "turn_on", + "payload": { + "duration_seconds": 120 + } +} +``` + +پاسخ نمونه: + +```json +{ + "code": 200, + "msg": "command accepted", + "data": { + "physical_device_uuid": "device-uuid", + "command": "turn_on", + "status": "queued" + } +} +``` + +--- + +## چه جاهایی باید در پروژه تغییر کند + +### 1) حذف وابستگی به `sensor_7_in_1` + +#### فایل‌هایی که باید refactor شوند + +- `device_hub/views.py` +- `device_hub/services.py` +- `device_hub/sensor_serializers.py` +- `device_hub/sensor_7_in_1_urls.py` +- `device_hub/comparison_urls.py` +- `device_hub/urls.py` + +#### چه چیزی باید تغییر کند + +- viewهای device-specific حذف شوند +- routeهای generic جایگزین شوند +- serviceهای `get_sensor_7_in_1_*` به serviceهای generic تبدیل شوند + +--- + +### 2) ساخت service عمومی برای پیدا کردن device + +در `device_hub/services.py` باید این لایه‌ها ایجاد شود: + +#### الف) resolver + +```python +get_farm_device_by_physical_uuid(physical_device_uuid) +get_device_catalog_for_farm_device(farm_device) +get_latest_device_log(farm_device) +get_device_logs(farm_device, range_value=None) +``` + +#### ب) normalizer + +```python +normalize_device_payload(device_catalog, payload) +extract_device_readings(device_catalog, payload) +``` + +این بخش باید از `payload_mapping` استفاده کند، نه از `SENSOR_FIELDS` ثابت. + +#### ج) presenter / builder + +```python +build_device_summary(farm_device) +build_device_values_list(farm_device, range_value) +build_device_comparison_chart(farm_device, range_value) +build_device_radar_chart(farm_device, range_value) +``` + +--- + +### 3) ثابت‌های hard-coded باید از کد خارج شوند + +الان این موارد hard-coded هستند: + +- `SENSOR_FIELDS` +- `COMPARISON_CHART_FIELD_ALIASES` +- `VALUES_LIST_FIELDS` +- `RADAR_CHART_FIELDS` + +این‌ها الان در: + +- `device_hub/services.py:16` + +هستند و باید به config وابسته به `DeviceCatalog` منتقل شوند. + +یعنی: + +- به‌جای constant سراسری +- از `device_catalog.display_schema` +- و `device_catalog.payload_mapping` + +استفاده شود. + +--- + +### 4) serializerهای اختصاصی باید generic شوند + +الان در: + +- `device_hub/sensor_serializers.py:6` + +serializerها مخصوص 7-in-1 هستند. + +باید این‌ها جایگزین شوند: + +- `DeviceMetaSerializer` +- `DeviceFieldValueSerializer` +- `DeviceValuesListSerializer` +- `DeviceSummarySerializer` +- `DeviceComparisonChartSerializer` +- `DeviceRadarChartSerializer` + +یعنی نام serializer نباید به یک device خاص گره خورده باشد. + +--- + +### 5) endpointهای generic بسازید + +در `device_hub/urls.py` بهتر است چیزی شبیه این داشته باشید: + +```python +urlpatterns = [ + path("catalog/", DeviceCatalogListView.as_view(), name="device-catalog-list"), + path("devices//device-codes/", DeviceCodeListView.as_view(), name="device-code-list"), + path("devices//", DeviceDetailView.as_view(), name="device-detail"), + path("devices//latest/", DeviceLatestPayloadView.as_view(), name="device-latest-payload"), + path("devices//summary/", DeviceSummaryView.as_view(), name="device-summary"), + path("devices//values-list/", DeviceValuesListView.as_view(), name="device-values-list"), + path("devices//comparison-chart/", DeviceComparisonChartView.as_view(), name="device-comparison-chart"), + path("devices//radar-chart/", DeviceRadarChartView.as_view(), name="device-radar-chart"), + path("devices//logs/", DeviceLogListView.as_view(), name="device-log-list"), + path("devices//commands/", DeviceCommandView.as_view(), name="device-command"), + path("external/", SensorExternalAPIView.as_view(), name="sensor-external-api"), +] +``` + +--- + +## روند اضافه کردن device جدید بدون تغییر کد + +بعد از این refactor، اضافه کردن device جدید باید این‌طوری باشد: + +### مرحله 1 + +یک رکورد جدید در `DeviceCatalog` ایجاد شود. + +### مرحله 2 + +این اطلاعات برایش ثبت شود: + +- `code` +- `name` +- `device_communication_type` +- `payload_mapping` +- `display_schema` +- `supported_widgets` +- `commands_schema` + +### مرحله 3 + +هنگام ثبت `FarmDevice`، آن device به همین catalog وصل شود. + +### مرحله 4 + +از این به بعد frontend فقط با `physical_device_uuid` به endpointهای generic می‌زند. + +بدون تغییر کد. + +--- + +## نمونه config برای یک سنسور خروجی‌محور + +```json +{ + "code": "soil_sensor_v2", + "name": "Soil Sensor V2", + "device_communication_type": "output_only", + "returned_data_fields": [ + "soil_moisture", + "soil_temperature", + "soil_ph" + ], + "payload_mapping": { + "soil_moisture": ["moisture", "soil_moisture"], + "soil_temperature": ["temperature", "soil_temperature"], + "soil_ph": ["ph", "soil_ph"] + }, + "display_schema": { + "fields": [ + { + "id": "soil_moisture", + "label": "رطوبت خاک", + "unit": "%", + "ideal_min": 45, + "ideal_max": 65 + }, + { + "id": "soil_temperature", + "label": "دمای خاک", + "unit": "°C", + "ideal_min": 18, + "ideal_max": 28 + }, + { + "id": "soil_ph", + "label": "PH خاک", + "unit": "pH", + "ideal_min": 6, + "ideal_max": 7.5 + } + ] + }, + "supported_widgets": [ + "values_list", + "comparison_chart", + "radar_chart", + "latest_payload" + ], + "commands_schema": [] +} +``` + +--- + +## نمونه config برای یک device فقط ورودی + +مثلا شیر برقی یا پمپ: + +```json +{ + "code": "irrigation_valve_v1", + "name": "Irrigation Valve V1", + "device_communication_type": "input_only", + "returned_data_fields": [], + "payload_mapping": {}, + "display_schema": { + "fields": [] + }, + "supported_widgets": [], + "commands_schema": [ + { + "command": "open", + "label": "باز کردن شیر", + "payload_schema": { + "duration_seconds": "integer" + } + }, + { + "command": "close", + "label": "بستن شیر", + "payload_schema": {} + } + ] +} +``` + +--- + +## پیشنهاد مرحله‌بندی پیاده‌سازی + +### فاز 1: Generic read API + +اول این‌ها را بسازید: + +- `DeviceDetailView` +- `DeviceLatestPayloadView` +- `DeviceSummaryView` +- `DeviceValuesListView` +- `DeviceComparisonChartView` +- `DeviceRadarChartView` + +و فعلا داده را با fallback از منطق فعلی بسازید. + +### فاز 2: Config-driven normalization + +بعد: + +- `payload_mapping` +- `display_schema` +- `supported_widgets` + +را به `DeviceCatalog` اضافه کنید و منطق hard-coded را حذف کنید. + +### فاز 3: Command API + +برای `input_only` deviceها: + +- `DeviceCommandView` +- command validation +- queue / external broker integration + +### فاز 4: Admin / CMS support + +برای اینکه بدون کد device جدید اضافه شود، باید از طریق: + +- Django Admin +یا +- پنل داخلی + +بتوانید `DeviceCatalog` را مدیریت کنید. + +--- + +## حداقل تغییر‌هایی که همین الان باید انجام بدهید + +اگر بخواهی با کمترین تغییر از ساختار فعلی به ساختار بهتر برسی، این‌ها مهم‌ترین کارها هستند: + +### ضروری + +1. حذف endpointهای `sensor_7_in_1`-محور +2. ساخت endpointهای generic با `physical_device_uuid` +3. جدا کردن منطق extraction از device-specific code +4. انتقال field mapping از constant به دیتابیس +5. اضافه کردن schema برای commandها + +### مهم ولی فاز بعدی + +1. admin برای `DeviceCatalog` +2. validation قوی برای `payload_mapping` +3. caching برای summary/chartها +4. swagger dynamic docs برای command schema + +--- + +## جمع‌بندی + +اگر هدفت این است که: + +- device جدید بدون تغییر کد اضافه شود +- frontend فقط با `device_uuid` کار کند +- بعضی deviceها فقط command بگیرند +- بعضی deviceها telemetry بدهند + +پس باید طراحی از: + +- `device-specific code` + +به این مدل تغییر کند: + +- `catalog-driven architecture` + +یعنی: + +- `DeviceCatalog` منبع حقیقت باشد +- APIها generic باشند +- parsing و rendering بر اساس config انجام شود +- commandها هم از schema خود device خوانده شوند + +--- + +## فایل‌های کلیدی برای refactor + +- `device_hub/models.py:6` +- `device_hub/views.py:19` +- `device_hub/services.py:16` +- `device_hub/sensor_serializers.py:1` +- `device_hub/urls.py:1` +- `device_hub/sensor_7_in_1_urls.py:1` +- `device_hub/comparison_urls.py:1` +- `device_hub/seeds.py:12` + +--- + +## پیشنهاد نهایی + +بهترین مسیر این است که: + +1. endpointهای generic را اضافه کنی +2. endpointهای قدیمی `sensor_7_in_1` را deprecated کنی +3. config مورد نیاز را به `DeviceCatalog` اضافه کنی +4. frontend را به `physical_device_uuid`-based API منتقل کنی + +اگر خواستی، در مرحله بعد من می‌توانم همین طراحی را به تسک اجرایی تبدیل کنم و دقیقا بگویم: + +- چه model fieldهایی اضافه شوند +- چه serializerهایی ساخته شوند +- چه endpointهایی پیاده شوند +- و refactor را در چه ترتیب انجام بدهی diff --git a/Modules/Backend/docs/fertilization_recommendation_frontend.md b/Modules/Backend/docs/fertilization_recommendation_frontend.md new file mode 100644 index 0000000..74fcbab --- /dev/null +++ b/Modules/Backend/docs/fertilization_recommendation_frontend.md @@ -0,0 +1,330 @@ +# Fertilization Recommendation History APIs + +این فایل برای تیم فرانت نوشته شده تا بتواند از APIهای history توصیه های کودهی استفاده کند. + +## وضعیت recommendation + +هر recommendation یک status دارد. + +### statusهای ممکن + +- `pending_confirmation` → `منتظر تایید` +- `in_progress` → `در حال مصرف` +- `completed` → `پایان یافته` + +### وضعیت فعلی سیستم + +فعلاً همه recommendationهای جدید و recommendationهای قبلی که migrate شده اند با وضعیت زیر ذخیره می شوند: + +- `pending_confirmation` +- برچسب نمایشی: `منتظر تایید` + +فرانت باید `status` را برای منطق برنامه و `status_label` را برای نمایش مستقیم استفاده کند. + +--- + +## 1) لیست توصیه های کودهی یک مزرعه + +### Endpoint + +`GET /api/fertilization/recommendations/?farm_uuid=` + +### کاربرد + +- نمایش history توصیه های کودهی یک مزرعه +- ساخت جدول یا لیست برای مشاهده توصیه های قبلی +- نمایش badge وضعیت recommendation +- ورود به صفحه جزئیات هر recommendation + +### Query Params + +- `farm_uuid`: شناسه مزرعه +- `crop_id`: شناسه یا نام محصول. این فیلد همان plant name است و مستقیم برای AI هم ارسال می شود +- `page`: شماره صفحه، شروع از `1` +- `page_size`: تعداد آیتم در هر صفحه، بین `1` تا `100` + +### هدرها + +- `Authorization: Bearer ` +- `Accept: application/json` + +### نمونه درخواست + +```bash +curl -X GET \ + 'http://localhost:8000/api/fertilization/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' +``` + +### نمونه پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "recommendation_uuid": "4d595ee0-9dbb-4c50-a871-2b4359d0d748", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "vegetative", + "fertilizer_type": "NPK", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-01-10T08:30:00Z" + }, + { + "recommendation_uuid": "bbdf0d50-0f78-4099-a4d3-b1c4aa54eeb9", + "crop_id": "ذرت", + "plant_name": "ذرت", + "growth_stage": "flowering", + "fertilizer_type": "Micronutrient", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-01-08T09:10:00Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 3, + "total_items": 25, + "has_next": true, + "has_previous": false, + "next": "http://localhost:8000/api/fertilization/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=10", + "previous": null + } +} +``` + +### فیلدهای `data[]` + +- `recommendation_uuid`: شناسه یکتای recommendation برای گرفتن جزئیات +- `crop_id`: شناسه یا نام محصول ثبت شده در recommendation +- `plant_name`: معادل نمایشی `crop_id` برای سازگاری با فرانت +- `growth_stage`: مرحله رشد در زمان ثبت recommendation +- `fertilizer_type`: نوع کود پیشنهادی مثل `NPK` +- `status`: کد وضعیت recommendation +- `status_label`: متن نمایشی وضعیت recommendation +- `requested_at`: زمان ثبت recommendation + +### فیلدهای `pagination` + +- `page`: صفحه فعلی +- `page_size`: تعداد آیتم در هر صفحه +- `total_pages`: تعداد کل صفحات +- `total_items`: تعداد کل recommendationها +- `has_next`: آیا صفحه بعدی وجود دارد یا نه +- `has_previous`: آیا صفحه قبلی وجود دارد یا نه +- `next`: لینک صفحه بعدی +- `previous`: لینک صفحه قبلی + +### پیشنهاد نمایش status در UI + +- `pending_confirmation` → badge زرد یا خاکستری روشن +- `in_progress` → badge آبی یا سبز +- `completed` → badge خاکستری یا سفید + +### خطاهای رایج + +#### مزرعه پیدا نشد + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +#### پارامترهای pagination نامعتبر + +```json +{ + "page": [ + "Ensure this value is greater than or equal to 1." + ] +} +``` + +--- + +## 2) جزئیات یک recommendation + +### Endpoint + +`GET /api/fertilization/recommendations//` + +### کاربرد + +- نمایش کامل جزئیات recommendation +- باز کردن صفحه detail یا modal recommendation +- replay کردن خروجی recommendation بدون نیاز به درخواست مجدد از AI + +### Path Param + +- `recommendation_uuid`: شناسه recommendation از API لیست + +### نکته مهم برای محصول + +- فیلد اصلی محصول در این ماژول `crop_id` است +- `crop_id` همان plant name است +- بک اند همان `crop_id` را مستقیم برای AI ارسال می کند +- `plant_name` در response فقط برای سازگاری فرانت نگه داشته شده و مقدارش برابر `crop_id` است + +### هدرها + +- `Authorization: Bearer ` +- `Accept: application/json` + +### نمونه درخواست + +```bash +curl -X GET \ + 'http://localhost:8000/api/fertilization/recommendations/4d595ee0-9dbb-4c50-a871-2b4359d0d748/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' +``` + +### نمونه پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "vegetative", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_name": "NPK 20-20-20", + "display_title": "کود کامل متعادل", + "fertilizer_type": "NPK", + "npk_ratio": { + "n": 20, + "p": 20, + "k": 20, + "label": "20-20-20" + }, + "application_method": { + "id": "fertigation", + "label": "کودآبیاری" + }, + "application_interval": { + "value": 14, + "unit": "day", + "label": "هر 14 روز" + }, + "dosage": { + "base_amount_per_hectare": 65, + "base_amount_per_square_meter": 0.0065, + "unit": "kg", + "label": "65 کیلوگرم در هکتار", + "calculation_basis": "engine-v2" + }, + "reasoning": "متعادل برای فاز رشد", + "summary": "مصرف منظم در این مرحله توصیه می شود" + }, + "nutrient_analysis": { + "macro": [ + { + "key": "n", + "name": "Nitrogen", + "value": 20, + "unit": "percent", + "description": "تقویت رشد رویشی" + } + ], + "micro": [] + }, + "application_guide": { + "safety_warning": "در ساعات خنک مصرف شود", + "steps": [ + { + "step_number": 1, + "title": "حل کردن", + "description": "کود را در آب حل کنید" + } + ] + }, + "alternative_recommendations": [ + { + "fertilizer_code": "npk-121236", + "fertilizer_name": "NPK 12-12-36", + "fertilizer_type": "NPK", + "usage_method": "fertigation", + "description": "برای نیاز پتاس بالا" + } + ], + "sections": [ + { + "type": "recommendation", + "title": "پیشنهاد اصلی", + "icon": "leaf", + "content": "NPK 20-20-20" + } + ] + } +} +``` + +### نکته مهم + +این response دقیقا همان ساختار endpoint زیر را برمی گرداند: + +`POST /api/fertilization/recommend/` + +یعنی فرانت می تواند برای صفحه detail همان componentهایی را استفاده کند که برای recommendation اصلی استفاده می کند. + +### خطای رایج + +#### recommendation پیدا نشد + +```json +{ + "code": 404, + "msg": "Recommendation not found." +} +``` + +--- + +## پیشنهاد پیاده سازی در فرانت + +### برای صفحه history + +- ابتدا API لیست را با `farm_uuid` صدا بزنید +- `data` را در جدول یا کارت لیست نمایش دهید +- `status_label` را مستقیم در badge یا chip نشان دهید +- اگر لازم بود رفتار UI بر اساس وضعیت تغییر کند، از `status` استفاده کنید +- با `pagination.page` و `pagination.total_pages` صفحه بندی را بسازید +- روی هر آیتم با `recommendation_uuid` به صفحه detail بروید + +### برای صفحه detail + +- `recommendation_uuid` را از route بگیرید +- API جزئیات را صدا بزنید +- `data.primary_recommendation` را در Hero/Card اصلی نمایش دهید +- `data.nutrient_analysis` را در بخش تحلیل عناصر نمایش دهید +- `data.application_guide` را در بخش راهنمای مصرف نمایش دهید +- `data.alternative_recommendations` را برای جایگزین ها نمایش دهید +- در صورت نیاز برای سازگاری، از `data.sections` هم استفاده کنید + +### فرمول محاسبه مقدار مصرف + +```text +مقدار کل = base_amount_per_square_meter × مساحت مزرعه +``` + +--- + +## خلاصه مسیرها + +- لیست recommendationها: + - `GET /api/fertilization/recommendations/?farm_uuid=&page=1&page_size=10` +- جزئیات recommendation: + - `GET /api/fertilization/recommendations//` diff --git a/Modules/Backend/docs/irrigation_fertilization_plan_parser_apis.md b/Modules/Backend/docs/irrigation_fertilization_plan_parser_apis.md new file mode 100644 index 0000000..fe462c7 --- /dev/null +++ b/Modules/Backend/docs/irrigation_fertilization_plan_parser_apis.md @@ -0,0 +1,619 @@ +# Free-Text Plan Parser APIs + +این فایل برای تیم فرانت‌اند آماده شده و دو API جدید زیر را توضیح می‌دهد: + +- `POST /api/irrigation/plan-from-text/` +- `POST /api/fertilization/plan-from-text/` + +هدف هر دو API: + +- کاربر یک متن آزاد می‌نویسد +- backend تلاش می‌کند برنامه آبیاری یا کودهی را به JSON ساختاریافته تبدیل کند +- اگر اطلاعات کامل باشد، JSON نهایی برمی‌گردد +- اگر اطلاعات ناقص باشد، API سوال‌های تکمیلی برمی‌گرداند +- فرانت‌اند سوال‌ها را از کاربر می‌پرسد و پاسخ‌ها را دوباره برای API می‌فرستد + +--- + +## رفتار کلی هر دو API + +هر دو endpoint یک flow یکسان دارند: + +1. کاربر متن آزاد اولیه را می‌فرستد +2. اگر متن کامل باشد: + - `status = "completed"` + - `final_plan` برمی‌گردد +3. اگر متن ناقص باشد: + - `status = "needs_clarification"` + - `missing_fields` برمی‌گردد + - `questions` برمی‌گردد +4. فرانت‌اند پاسخ کاربر به سوال‌ها را جمع می‌کند +5. دوباره همان endpoint را با `answers` و `partial_plan` صدا می‌زند +6. این روند تا ساخته شدن `final_plan` ادامه پیدا می‌کند + +--- + +## الگوی کلی response + +هر دو API از envelope استاندارد استفاده می‌کنند: + +```json +{ + "code": 200, + "msg": "موفق", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | number | کد منطقی پاسخ | +| `msg` | string | پیام کوتاه پاسخ | +| `data` | object | داده اصلی API | + +--- + +## 1) API استخراج برنامه آبیاری + +### Endpoint + +```http +POST /api/irrigation/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه آبیاری را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +هر سه فیلد زیر اختیاری هستند، اما حداقل یکی از این‌ها باید ارسال شود: + +- `message` +- `answers` +- `partial_plan` + +#### ساختار request + +```json +{ + "message": "برای گوجه فرنگی با آبیاری قطره ای هر سه روز یک بار صبح زود 25 دقیقه آبیاری می کنم و حدود 18 لیتر برای هر بوته می دهم.", + "answers": { + "growth_stage": "گلدهی" + }, + "partial_plan": { + "crop_name": "گوجه فرنگی", + "irrigation_method": "قطره ای" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر به سوال‌هایی که قبلا API داده | +| `partial_plan` | object | خیر | خروجی مرحله قبل برای ادامه تکمیل | +| `farm_uuid` | string | خیر | برای غنی‌سازی context مزرعه در AI | + +### قانون validation + +اگر هیچ‌کدام از `message`، `answers` یا `partial_plan` ارسال نشوند: + +```json +{ + "code": 400, + "msg": "Bad Request", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +وقتی همه اطلاعات لازم موجود باشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه آبیاری برای گوجه‌فرنگی به روش قطره‌ای هر سه روز یک‌بار صبح زود به مدت 25 دقیقه اجرا می‌شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + }, + "final_plan": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": "گلدهی", + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه", + "trigger_conditions": [], + "notes": [] + } + } +} +``` + +### فیلدهای `data` + +| فیلد | نوع | توضیح | +|---|---|---| +| `status` | string | یکی از `completed` یا `needs_clarification` | +| `status_fa` | string | نسخه فارسی وضعیت | +| `summary` | string | خلاصه قابل نمایش برای کاربر | +| `missing_fields` | array[string] | فیلدهای ناقص | +| `questions` | array[object] | سوال‌های تکمیلی | +| `collected_data` | object | داده‌ای که تا الان از متن و جواب‌ها استخراج شده | +| `final_plan` | object/null | برنامه نهایی؛ فقط در حالت `completed` | + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد محصول | +| `irrigation_method` | string | روش آبیاری | +| `water_amount_per_event` | string | مقدار آب هر نوبت | +| `duration_minutes` | number | مدت هر نوبت آبیاری به دقیقه | +| `frequency_text` | string | توصیف متنی فاصله آبیاری | +| `interval_days` | number | فاصله آبیاری بر حسب روز | +| `preferred_time_of_day` | string | زمان مناسب اجرای آبیاری | +| `start_date` | string | زمان یا تاریخ شروع برنامه | +| `target_area` | string | محدوده هدف برنامه | +| `trigger_conditions` | array[string] | شرایط تریگر اختیاری | +| `notes` | array[string] | نکات تکمیلی | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +اگر اطلاعات کامل نباشد: + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "start_date", + "target_area" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای کامل شدن برنامه لازم است." + }, + { + "id": "start_date", + "field": "start_date", + "question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟", + "rationale": "زمان شروع برنامه هنوز مشخص نشده است." + }, + { + "id": "target_area", + "field": "target_area", + "question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟", + "rationale": "محدوده اجرای برنامه باید مشخص باشد." + } + ], + "collected_data": { + "crop_name": "گوجه‌فرنگی", + "growth_stage": null, + "irrigation_method": "قطره‌ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "frequency_text": "هر سه روز یک‌بار", + "interval_days": 3, + "preferred_time_of_day": "صبح زود", + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "final_plan": null + } +} +``` + +### ساختار `questions` + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | string | شناسه سوال | +| `field` | string | فیلدی که این سوال برای آن پرسیده شده | +| `question` | string | متن سوال برای نمایش به کاربر | +| `rationale` | string | توضیح کوتاه برای اینکه چرا این سوال لازم است | + +--- + +## flow پیشنهادی فرانت‌اند برای آبیاری + +### مرحله 1 + +کاربر متن آزاد می‌فرستد: + +```json +{ + "message": "برای گوجه فرنگی هر سه روز یک بار آبیاری می کنم." +} +``` + +### مرحله 2 + +اگر `status = needs_clarification` بود: + +- سوال‌ها را از `data.questions` به کاربر نمایش بده +- پاسخ‌ها را جمع کن + +### مرحله 3 + +درخواست تکمیلی بزن: + +```json +{ + "partial_plan": { + "crop_name": "گوجه فرنگی", + "growth_stage": null, + "irrigation_method": null, + "water_amount_per_event": null, + "duration_minutes": null, + "frequency_text": "هر سه روز یک بار", + "interval_days": 3, + "preferred_time_of_day": null, + "start_date": null, + "target_area": null, + "trigger_conditions": [], + "notes": [] + }, + "answers": { + "growth_stage": "گلدهی", + "irrigation_method": "قطره ای", + "water_amount_per_event": "18 لیتر برای هر بوته", + "duration_minutes": 25, + "preferred_time_of_day": "صبح زود", + "start_date": "از امروز", + "target_area": "کل مزرعه" + } +} +``` + +### مرحله 4 + +اگر `status = completed` شد: + +- از `data.final_plan` به عنوان JSON نهایی استفاده کن + +--- + +## 2) API استخراج برنامه کودهی + +### Endpoint + +```http +POST /api/fertilization/plan-from-text/ +``` + +### کاربرد + +این API متن آزاد کاربر درباره برنامه کودهی را به JSON ساختاریافته تبدیل می‌کند. + +### Request Body + +```json +{ + "message": "برای گندم در مرحله پنجه زنی هر 12 روز یک بار 20-20-20 به مقدار 35 کیلوگرم در هکتار از طریق کودآبیاری می دهم.", + "answers": { + "timing": "هر 12 روز یک بار" + }, + "partial_plan": { + "crop_name": "گندم" + }, + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### فیلدهای request + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `message` | string | خیر | متن آزاد کاربر | +| `answers` | object | خیر | پاسخ‌های تکمیلی کاربر | +| `partial_plan` | object | خیر | داده استخراج شده مرحله قبل | +| `farm_uuid` | string | خیر | برای context مزرعه | + +### validation error + +```json +{ + "code": 400, + "msg": "Bad Request", + "data": { + "non_field_errors": [ + "حداقل یکی از message، answers یا partial_plan باید ارسال شود." + ] + } +} +``` + +--- + +## پاسخ موفق - حالت تکمیل شده + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "برنامه کودهی برای گندم در مرحله پنجه زنی با کود 20-20-20 به صورت کودآبیاری هر 12 روز یک بار اجرا می شود.", + "missing_fields": [], + "questions": [], + "collected_data": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + }, + "final_plan": { + "crop_name": "گندم", + "growth_stage": "پنجه زنی", + "objective": "تقویت رشد رویشی", + "applications": [ + { + "fertilizer_name": "کود کامل 20-20-20", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12, + "purpose": "تقویت رشد رویشی" + } + ], + "notes": [] + } + } +} +``` + +### فیلدهای `collected_data` و `final_plan` + +| فیلد | نوع | توضیح | +|---|---|---| +| `crop_name` | string | نام محصول | +| `growth_stage` | string | مرحله رشد | +| `objective` | string/null | هدف برنامه | +| `applications` | array[object] | لیست نوبت‌ها یا اقلام کودی | +| `notes` | array[string] | نکات تکمیلی | + +### ساختار هر application + +| فیلد | نوع | توضیح | +|---|---|---| +| `fertilizer_name` | string | نام کود | +| `formula` | string | فرمول یا آنالیز کود | +| `amount` | string | مقدار مصرف | +| `application_method` | string | روش مصرف | +| `timing` | string | زمان‌بندی مصرف | +| `interval_days` | number | فاصله بین نوبت‌ها | +| `purpose` | string/null | هدف آن نوبت | + +--- + +## پاسخ موفق - حالت نیاز به سوال تکمیلی + +```json +{ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.", + "missing_fields": [ + "growth_stage", + "formula", + "interval_days" + ], + "questions": [ + { + "id": "growth_stage", + "field": "growth_stage", + "question": "محصول الان در چه مرحله رشدی قرار دارد؟", + "rationale": "مرحله رشد برای تکمیل برنامه لازم است." + }, + { + "id": "formula", + "field": "formula", + "question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.", + "rationale": "ترکیب دقیق کود هنوز مشخص نشده است." + }, + { + "id": "interval_days", + "field": "interval_days", + "question": "فاصله بین نوبت های مصرف کود چند روز است؟", + "rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است." + } + ], + "collected_data": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر چند وقت یک بار", + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "final_plan": null + } +} +``` + +--- + +## flow پیشنهادی فرانت‌اند برای کودهی + +### درخواست اولیه + +```json +{ + "message": "برای گندم از کود کامل استفاده می کنم." +} +``` + +### اگر incomplete بود + +- از `questions` سوال‌ها را بگیر +- در UI نمایش بده +- پاسخ‌ها را جمع کن + +### درخواست تکمیلی + +```json +{ + "partial_plan": { + "crop_name": "گندم", + "growth_stage": null, + "objective": null, + "applications": [ + { + "fertilizer_name": "کود کامل", + "formula": null, + "amount": null, + "application_method": null, + "timing": null, + "interval_days": null, + "purpose": null + } + ], + "notes": [] + }, + "answers": { + "growth_stage": "پنجه زنی", + "formula": "20-20-20", + "amount": "35 کیلوگرم در هکتار", + "application_method": "کودآبیاری", + "timing": "هر 12 روز یک بار", + "interval_days": 12 + } +} +``` + +### اگر complete شد + +- از `final_plan` استفاده کن + +--- + +## نکات مهم برای فرانت‌اند + +### 1. به `status` تکیه کنید + +مهم‌ترین فیلد برای کنترل flow: + +- `completed` +- `needs_clarification` + +### 2. اگر `needs_clarification` بود + +باید: + +- `questions` را به کاربر نمایش دهید +- `partial_plan` را نگه دارید +- پاسخ‌های کاربر را در `answers` ارسال کنید + +### 3. اگر `completed` بود + +باید: + +- `final_plan` را به عنوان نسخه نهایی برنامه ذخیره یا نمایش دهید + +### 4. `collected_data` همیشه مهم است + +حتی اگر برنامه ناقص باشد، `collected_data` نشان می‌دهد سیستم تا این لحظه چه چیزهایی را فهمیده است. + +### 5. null در حالت ناقص طبیعی است + +در حالت `needs_clarification` ممکن است بعضی فیلدهای `collected_data` `null` باشند. +اما در حالت `completed` نباید فیلدهای اصلی ناقص باشند. + +### 6. بهتر است سوال‌ها را step-by-step بپرسید + +پیشنهاد: + +- سوال اول را نشان بده +- جواب را بگیر +- همه جواب‌ها را در `answers` جمع کن +- دوباره API را صدا بزن + +--- + +## جمع‌بندی تفاوت دو API + +| API | موضوع | خروجی نهایی | +|---|---|---| +| `/api/irrigation/plan-from-text/` | استخراج برنامه آبیاری | `final_plan` با ساختار آبیاری | +| `/api/fertilization/plan-from-text/` | استخراج برنامه کودهی | `final_plan` با ساختار کودهی | + +--- + +## مسیر فایل + +این داکیومنت در این مسیر ذخیره شده: + +`docs/irrigation_fertilization_plan_parser_apis.md` diff --git a/Modules/Backend/docs/pest_disease_risk_summary_frontend_api_reference.md b/Modules/Backend/docs/pest_disease_risk_summary_frontend_api_reference.md new file mode 100644 index 0000000..e96c430 --- /dev/null +++ b/Modules/Backend/docs/pest_disease_risk_summary_frontend_api_reference.md @@ -0,0 +1,247 @@ +# Pest Disease Risk Summary API Reference + +این فایل برای فرانت آماده شده تا ساختار خروجی endpoint زیر مشخص و قابل استفاده باشد: + +```http +POST /api/pest-disease/risk-summary/ +``` + +## Purpose + +این endpoint فقط `farm_uuid` می‌گیرد و در backend: + +- مزرعه را پیدا می‌کند +- اولین محصول ثبت‌شده روی همان مزرعه را برمی‌دارد +- `plant_name` را از همان محصول پر می‌کند +- `growth_stage` را فعلاً به صورت ثابت `گلدهی` به سرویس AI می‌فرستد +- خروجی خلاصه ریسک آفت و بیماری را به فرانت برمی‌گرداند + +--- + +## Request + +### Body + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### Request Fields + +| field | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه | + +### Important Notes + +- این endpoint فقط `farm_uuid` را از کلاینت قبول می‌کند. +- `plant_name` نباید از فرانت ارسال شود. +- `growth_stage` نباید از فرانت ارسال شود. +- `plant_name` در backend از اولین محصول مزرعه استخراج می‌شود. +- اگر مزرعه هیچ محصولی نداشته باشد، `plant_name` به صورت رشته خالی به AI ارسال می‌شود. +- `growth_stage` فعلاً همیشه `گلدهی` است. + +--- + +## Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "diseaseRisk": { + "id": "disease-risk", + "title": "ریسک بیماری", + "subtitle": "فشار بیماری در حال افزایش است", + "stats": "68%", + "avatarColor": "warning", + "avatarIcon": "tabler-biohazard", + "chipText": "متوسط", + "chipColor": "warning", + "details": { + "reason": "رطوبت بالا و تهویه ضعیف" + } + }, + "pestRisk": { + "id": "pest-risk", + "title": "ریسک آفت", + "subtitle": "فعالیت آفات قابل توجه است", + "stats": "41%", + "avatarColor": "info", + "avatarIcon": "tabler-bug", + "chipText": "کم", + "chipColor": "success", + "details": { + "reason": "شرایط محیطی نسبتاً پایدار" + } + }, + "drivers": { + "humidity": "high", + "temperature": "moderate" + } + } +} +``` + +--- + +## Success Response Fields + +### Top Level + +| field | type | description | +|---|---|---| +| `code` | `number` | در حالت موفق مقدار `200` | +| `msg` | `string` | در حالت موفق مقدار `success` | +| `data` | `object` | محتوای اصلی پاسخ | + +### `data` + +| field | type | description | +|---|---|---| +| `farm_uuid` | `string` | UUID مزرعه | +| `diseaseRisk` | `object` | کارت ریسک بیماری | +| `pestRisk` | `object` | کارت ریسک آفت | +| `drivers` | `object` | عوامل موثر روی ریسک | + +### `diseaseRisk` / `pestRisk` + +هر دو فیلد `diseaseRisk` و `pestRisk` یک ساختار مشابه دارند: + +| field | type | description | +|---|---|---| +| `id` | `string` | شناسه کارت | +| `title` | `string` | عنوان کارت | +| `subtitle` | `string` | توضیح کوتاه | +| `stats` | `string` | عدد یا درصد اصلی برای نمایش | +| `avatarColor` | `string` | رنگ آیکن یا کارت | +| `avatarIcon` | `string` | نام آیکن | +| `chipText` | `string` | وضعیت متنی مثل `کم`، `متوسط`، `زیاد` | +| `chipColor` | `string` | رنگ وضعیت مثل `success`، `warning`، `error` | +| `details` | `object` | اطلاعات تکمیلی برای UI جزئی‌تر | + +### `drivers` + +| field | type | description | +|---|---|---| +| `drivers` | `object` | object آزاد از عوامل مؤثر مثل رطوبت، دما، باد، بارندگی و غیره | + +نکته: +- ساختار داخلی `drivers` ثابت و محدود نیست و بهتر است در فرانت به صورت dynamic render شود. + +--- + +## Error Response - Missing `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### When Happens + +- وقتی `farm_uuid` در body ارسال نشده باشد + +--- + +## Error Response - Farm Not Found + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### When Happens + +- وقتی `farm_uuid` معتبر باشد ولی مزرعه‌ای با آن پیدا نشود +- یا مزرعه متعلق به کاربر فعلی نباشد + +--- + +## Error Response - Upstream / AI Error + +اگر سرویس AI خطا برگرداند، backend همان status code را با این ساختار پاس می‌دهد: + +```json +{ + "code": 500, + "msg": "error", + "data": { + "message": "Upstream service error" + } +} +``` + +نکته: +- محتویات `data` در این حالت بسته به پاسخ upstream ممکن است متفاوت باشد. + +--- + +## Frontend Notes + +- فرم درخواست فقط باید `farm_uuid` بفرستد. +- `diseaseRisk` و `pestRisk` را مثل card model در UI مصرف کنید. +- `drivers` را optional و dynamic در نظر بگیرید. +- اگر یکی از `diseaseRisk` یا `pestRisk` خالی بود، UI باید بدون crash کار کند. +- متن خطا برای `400` و `404` را می‌توانید از `data.farm_uuid[0]` بخوانید. + +--- + +## Suggested TypeScript Interfaces + +```ts +export interface RiskCard { + id?: string; + title?: string; + subtitle?: string; + stats?: string; + avatarColor?: string; + avatarIcon?: string; + chipText?: string; + chipColor?: string; + details?: Record; +} + +export interface PestDiseaseRiskSummaryResponse { + code: number; + msg: string; + data: { + farm_uuid: string; + diseaseRisk?: RiskCard; + pestRisk?: RiskCard; + drivers?: Record; + }; +} +``` + +--- + +## Example Frontend Handling + +```ts +const response = await api.post('/api/pest-disease/risk-summary/', { + farm_uuid, +}); + +const result = response.data; + +if (result.code === 200) { + const diseaseRisk = result.data.diseaseRisk; + const pestRisk = result.data.pestRisk; + const drivers = result.data.drivers; +} +``` diff --git a/Modules/Backend/docs/recommend_task_status_frontend_backend.md b/Modules/Backend/docs/recommend_task_status_frontend_backend.md new file mode 100644 index 0000000..148f08e --- /dev/null +++ b/Modules/Backend/docs/recommend_task_status_frontend_backend.md @@ -0,0 +1,267 @@ +# Recommend Task Status API Guide + +این فایل برای تیم فرانت‌اند توضیح می‌دهد که برای ماژول‌های `fertilization` و `irrigation` چه درخواست‌هایی باید به بک‌اند ارسال شود و چه پاسخ‌هایی باید دریافت شود. + +## Fertilization Recommendation + +### 1) ثبت درخواست پیشنهاد + +**Endpoint** + +`POST /api/fertilization-recommendation/recommend/` + +**Request Body** + +```json +{ + "crop_id": "wheat", + "growth_stage": "tillering", + "farm_data": { + "soilType": "loam", + "organicMatter": "medium", + "waterEC": "1.2" + }, + "soilType": "loam", + "organicMatter": "medium", + "waterEC": "1.2" +} +``` + +**Field Description** + +- `crop_id`: شناسه محصول +- `growth_stage`: مرحله رشد محصول +- `farm_data.soilType`: نوع خاک +- `farm_data.organicMatter`: مقدار ماده آلی +- `farm_data.waterEC`: EC آب +- `soilType`, `organicMatter`, `waterEC`: همین داده‌ها اگر فرانت بخواهد به صورت flat هم ارسال کند + +**Success Response** + +اگر سرویس خارجی مستقیم نتیجه را برگرداند: + +```json +{ + "status": "success", + "data": { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "drip", + "applicationInterval": "every 14 days", + "reasoning": "balanced nutrition for current growth stage" + } + } +} +``` + +اگر سرویس خارجی async باشد، معمولاً `task_id` برمی‌گرداند: + +```json +{ + "status": "success", + "data": { + "task_id": "fert-task-123", + "status": "pending" + } +} +``` + +### 2) دریافت وضعیت تسک + +**Endpoint** + +`GET /api/fertilization-recommendation/recommend/status/{task_id}/` + +**Path Param** + +- `task_id`: شناسه تسکی که از مرحله قبل گرفته شده + +**Success Response** + +```json +{ + "status": "success", + "data": { + "task_id": "fert-task-123", + "status": "processing", + "progress": { + "message": "analyzing farm data" + }, + "result": { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "drip", + "applicationInterval": "every 14 days", + "reasoning": "balanced nutrition for current growth stage" + } + } + } +} +``` + +**Possible status values** + +- `pending` +- `processing` +- `completed` +- `failed` + +--- + +## Irrigation Recommendation + +### 1) ثبت درخواست پیشنهاد + +**Endpoint** + +`POST /api/irrigation-recommendation/recommend/` + +**Request Body** + +```json +{ + "crop_id": "wheat", + "farm_data": { + "soilType": "loam", + "waterQuality": "good", + "climateZone": "semi-arid" + }, + "soilType": "loam", + "waterQuality": "good", + "climateZone": "semi-arid" +} +``` + +**Field Description** + +- `crop_id`: شناسه محصول +- `farm_data.soilType`: نوع خاک +- `farm_data.waterQuality`: کیفیت آب +- `farm_data.climateZone`: اقلیم +- `soilType`, `waterQuality`, `climateZone`: همین داده‌ها در حالت flat + +**Success Response** + +حالت نتیجه مستقیم: + +```json +{ + "status": "success", + "data": { + "plan": { + "frequencyPerWeek": "3", + "durationMinutes": "45", + "bestTimeOfDay": "early morning", + "moistureLevel": "optimal", + "warning": "avoid irrigation during strong wind" + }, + "raw_response": "...", + "water_balance": { + "daily": [ + { + "forecast_date": "2025-03-28", + "et0_mm": 4.1, + "etc_mm": 3.8, + "effective_rainfall_mm": 0.0, + "gross_irrigation_mm": 3.8, + "irrigation_timing": "06:00" + } + ], + "crop_profile": { + "kc_initial": 0.7, + "kc_mid": 1.05, + "kc_end": 0.85 + }, + "active_kc": 1.05 + }, + "status": "completed" + } +} +``` + +حالت async: + +```json +{ + "status": "success", + "data": { + "task_id": "irr-task-123", + "status": "pending" + } +} +``` + +### 2) دریافت وضعیت تسک + +**Endpoint** + +`GET /api/irrigation-recommendation/recommend/status/{task_id}/` + +**Path Param** + +- `task_id`: شناسه تسک + +**Success Response** + +```json +{ + "status": "success", + "data": { + "task_id": "irr-task-123", + "status": "completed", + "result": { + "plan": { + "frequencyPerWeek": "3", + "durationMinutes": "45", + "bestTimeOfDay": "early morning", + "moistureLevel": "optimal", + "warning": "avoid irrigation during strong wind" + }, + "raw_response": "...", + "water_balance": { + "daily": [], + "crop_profile": { + "kc_initial": 0.7, + "kc_mid": 1.05, + "kc_end": 0.85 + }, + "active_kc": 1.05 + }, + "status": "completed" + } + } +} +``` + +--- + +## پیشنهاد پیاده‌سازی در فرانت + +### Fertilization + +1. با `POST /recommend/` درخواست را ارسال کنید. +2. اگر `data.task_id` برگشت، polling را با `GET /recommend/status/{task_id}/` شروع کنید. +3. وقتی `data.status` به `completed` رسید، `data.result` را نمایش دهید. +4. اگر `failed` شد، پیام خطا را به کاربر نشان دهید. + +### Irrigation + +1. با `POST /recommend/` درخواست را ارسال کنید. +2. اگر `task_id` برگشت، هر چند ثانیه وضعیت را چک کنید. +3. وقتی `completed` شد، `result.plan` و `result.water_balance` را نمایش دهید. + +## نکات + +- همه پاسخ‌ها در این پروژه معمولاً با ساختار زیر برمی‌گردند: + +```json +{ + "status": "success", + "data": {} +} +``` + +- در صورت خطا ممکن است `status` مقدار دیگری داشته باشد یا سرویس خارجی خطای مستقیم برگرداند. +- فرانت باید هر دو حالت **direct result** و **task-based result** را هندل کند. diff --git a/Modules/Backend/docs/sensor_frontend_api_reference.md b/Modules/Backend/docs/sensor_frontend_api_reference.md new file mode 100644 index 0000000..bdce862 --- /dev/null +++ b/Modules/Backend/docs/sensor_frontend_api_reference.md @@ -0,0 +1,1062 @@ +# مستند فرانت 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/` + +--- + +## 1) نکات کلی + +### Base URL + +همه مسیرها نسبت به دامنه اصلی backend تعریف می‌شوند. مثال: + +```txt +https://example.com/api/sensor-7-in-1/summary/ +``` + +### نوع احراز هویت + +این endpointها دو مدل احراز هویت دارند: + +1. endpointهای سنسور 7-in-1: + - `GET /api/sensor-7-in-1/summary/` + - `GET /api/sensors/comparison-chart/` + - `GET /api/sensors/radar-chart/` + - `GET /api/sensors/values-list/` + - نیازمند کاربر لاگین‌شده هستند. + - اگر کاربر احراز هویت نشده باشد معمولاً پاسخ `401 Unauthorized` برمی‌گردد. + +2. endpoint لاگ سنسور خارجی: + - `GET /api/sensor-external-api/logs/` + - نیازمند هدر `X-API-Key` است. + - اگر API key ارسال نشود یا اشتباه باشد پاسخ `401 Unauthorized` برمی‌گردد. + +### نکته مهم درباره ساختار response + +این 5 API یکدست نیستند: + +- `summary` پاسخ را داخل ساختار استاندارد `code / msg / data` برمی‌گرداند. +- `comparison-chart`، `radar-chart` و `values-list` داده خام را مستقیم برمی‌گردانند. +- `sensor-external-api/logs` پاسخ را به‌صورت paginated و داخل `code / msg / data` برمی‌گرداند. + +پس فرانت باید برای هر endpoint دقیقاً همان ساختار را هندل کند. + +--- + +## 2) GET /api/sensor-7-in-1/summary/ + +### هدف + +این endpoint یک summary کامل از سنسور 7-in-1 مزرعه برمی‌گرداند و چند ویجت آماده برای UI را یکجا در خروجی قرار می‌دهد: + +- اطلاعات متای سنسور +- لیست مقادیر سنسور +- کارت میانگین رطوبت خاک +- نمودار رادار +- نمودار مقایسه‌ای +- کارت anomaly detection +- heatmap رطوبت خاک + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | `uuid` | بله | شناسه مزرعه | + +### نمونه درخواست + +```http +GET /api/sensor-7-in-1/summary/?farm_uuid=11111111-1111-1111-1111-111111111111 +Authorization: Bearer +``` + +### ساختار پاسخ موفق + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "sensor": {}, + "sensorValuesList": {}, + "avgSoilMoisture": {}, + "sensorRadarChart": {}, + "sensorComparisonChart": {}, + "anomalyDetectionCard": {}, + "soilMoistureHeatmap": {} + } +} +``` + +### فیلدهای `data` + +#### `sensor` + +متادیتای سنسور اصلی: + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | `string` | نام سنسور | +| `physicalDeviceUuid` | `string \| null` | شناسه فیزیکی دستگاه | +| `sensorCatalogCode` | `string` | کد سنسور در catalog | +| `updatedAt` | `string \| null` | زمان آخرین لاگ به فرمت ISO | + +نمونه: + +```json +{ + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" +} +``` + +#### `sensorValuesList` + +لیست مقادیر فعلی سنسور برای نمایش کارت‌ها یا stat itemها: + +| فیلد | نوع | توضیح | +|---|---|---| +| `sensor` | `object` | همان متادیتای سنسور | +| `sensors` | `array` | لیست سنسورفیلدها | + +ساختار هر آیتم `sensors`: + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `string` | کلید داخلی فیلد مثل `soil_moisture` | +| `title` | `string` | مقدار فرمت‌شده برای نمایش | +| `subtitle` | `string` | عنوان فارسی فیلد | +| `trendNumber` | `number` | مقدار تغییر نسبت به لاگ قبلی | +| `trend` | `positive \| negative` | جهت تغییر | +| `unit` | `string` | واحد | + +نمونه: + +```json +{ + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensors": [ + { + "id": "soil_moisture", + "title": "48.5%", + "subtitle": "رطوبت خاک", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "id": "soil_temperature", + "title": "23.2°C", + "subtitle": "دمای خاک", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + } + ] +} +``` + +#### `avgSoilMoisture` + +کارت KPI برای میانگین رطوبت خاک: + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `string` | شناسه کارت | +| `title` | `string` | عنوان کارت | +| `subtitle` | `string` | زیرعنوان | +| `stats` | `string` | مقدار اصلی برای نمایش | +| `avatarColor` | `string` | رنگ آیکن/اواتار | +| `avatarIcon` | `string` | نام آیکن | +| `chipText` | `string` | متن وضعیت | +| `chipColor` | `string` | رنگ وضعیت | + +نمونه: + +```json +{ + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": "48.5%", + "avatarColor": "warning", + "avatarIcon": "tabler-droplet", + "chipText": "متوسط", + "chipColor": "warning" +} +``` + +#### `sensorRadarChart` + +داده آماده برای radar chart: + +| فیلد | نوع | توضیح | +|---|---|---| +| `labels` | `string[]` | نام محورها | +| `series` | `array` | سری‌های نمودار | + +ساختار هر `series`: + +| فیلد | نوع | +|---|---| +| `name` | `string` | +| `data` | `number[]` | + +نمونه: + +```json +{ + "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], + "series": [ + { + "name": "اکنون", + "data": [86.0, 96.0, 88.0, 84.0, 76.0, 88.0, 44.0] + }, + { + "name": "هدف", + "data": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0] + } + ] +} +``` + +#### `sensorComparisonChart` + +خروجی آماده برای line/area chart: + +| فیلد | نوع | توضیح | +|---|---|---| +| `currentValue` | `number` | مقدار آخر | +| `vsLastWeek` | `string` | درصد تغییر نسبت به اولین نقطه | +| `vsLastWeekValue` | `number` | نسخه عددی تغییر | +| `categories` | `string[]` | برچسب محور X | +| `series` | `array` | سری‌های نمودار | + +نمونه: + +```json +{ + "currentValue": 48.5, + "vsLastWeek": "+18.3%", + "vsLastWeekValue": 18.3, + "categories": ["01/04 08:00", "01/10 08:30"], + "series": [ + { + "name": "رطوبت خاک", + "data": [41.0, 48.5] + }, + { + "name": "بازه هدف", + "data": [55.0, 55.0] + } + ] +} +``` + +#### `anomalyDetectionCard` + +لیست ناهنجاری‌های سنسور: + +| فیلد | نوع | توضیح | +|---|---|---| +| `anomalies` | `array` | لیست anomaly | + +ساختار هر anomaly: + +| فیلد | نوع | توضیح | +|---|---|---| +| `sensor` | `string` | نام سنسور/پارامتر | +| `value` | `string` | مقدار فعلی | +| `expected` | `string` | بازه مورد انتظار | +| `deviation` | `string` | اختلاف با مقدار مورد انتظار | +| `severity` | `success \| warning \| error` | شدت | + +نمونه: + +```json +{ + "anomalies": [ + { + "sensor": "پتاسیم", + "value": "24 mg/kg", + "expected": "15-35 mg/kg", + "deviation": "0", + "severity": "success" + } + ] +} +``` + +نکته: + +- اگر ناهنجاری واقعی وجود نداشته باشد، backend یک آیتم success برمی‌گرداند تا UI حالت خالی نداشته باشد. + +#### `soilMoistureHeatmap` + +خروجی heatmap: + +| فیلد | نوع | توضیح | +|---|---|---| +| `zones` | `string[]` | نام zoneها یا سنسورها | +| `hours` | `string[]` | محور زمان | +| `series` | `array` | داده heatmap | + +نمونه: + +```json +{ + "zones": ["Soil Sensor 7-in-1"], + "hours": ["08:00", "10:00"], + "series": [ + { + "name": "Soil Sensor 7-in-1", + "data": [ + { "x": "08:00", "y": 41.0 }, + { "x": "10:00", "y": 48.5 } + ] + } + ] +} +``` + +### نمونه پاسخ کامل موفق + +```json +{ + "code": 200, + "msg": "OK", + "data": { + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensorValuesList": { + "sensor": { + "name": "Soil Sensor 7-in-1", + "physicalDeviceUuid": "33333333-3333-3333-3333-333333333333", + "sensorCatalogCode": "sensor-7-in-1", + "updatedAt": "2025-01-10T08:30:00Z" + }, + "sensors": [ + { + "id": "soil_moisture", + "title": "48.5%", + "subtitle": "رطوبت خاک", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "id": "soil_temperature", + "title": "23.2°C", + "subtitle": "دمای خاک", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + }, + { + "id": "soil_ph", + "title": "6.8", + "subtitle": "pH خاک", + "trendNumber": 0.3, + "trend": "positive", + "unit": "pH" + }, + { + "id": "electrical_conductivity", + "title": "1.4 dS/m", + "subtitle": "هدایت الکتریکی", + "trendNumber": 0.4, + "trend": "positive", + "unit": "dS/m" + } + ] + }, + "avgSoilMoisture": { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "سنسور 7 در 1 خاک", + "stats": "48.5%", + "avatarColor": "warning", + "avatarIcon": "tabler-droplet", + "chipText": "متوسط", + "chipColor": "warning" + }, + "sensorRadarChart": { + "labels": ["رطوبت", "دما", "pH", "EC", "نیتروژن", "فسفر", "پتاسیم"], + "series": [ + { + "name": "اکنون", + "data": [86.0, 96.0, 88.0, 84.0, 76.0, 88.0, 44.0] + }, + { + "name": "هدف", + "data": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0] + } + ] + }, + "sensorComparisonChart": { + "currentValue": 48.5, + "vsLastWeek": "+18.3%", + "vsLastWeekValue": 18.3, + "categories": ["01/04 08:00", "01/10 08:30"], + "series": [ + { + "name": "رطوبت خاک", + "data": [41.0, 48.5] + }, + { + "name": "بازه هدف", + "data": [55.0, 55.0] + } + ] + }, + "anomalyDetectionCard": { + "anomalies": [ + { + "sensor": "سنسور 7 در 1 خاک", + "value": "نرمال", + "expected": "تمام شاخص‌ها در بازه مجاز هستند", + "deviation": "0", + "severity": "success" + } + ] + }, + "soilMoistureHeatmap": { + "zones": ["Soil Sensor 7-in-1"], + "hours": ["08:00", "10:00"], + "series": [ + { + "name": "Soil Sensor 7-in-1", + "data": [ + { "x": "08:00", "y": 41.0 }, + { "x": "10:00", "y": 48.5 } + ] + } + ] + } + } +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `farm_uuid` ارسال نشود: + +```json +{ + "farm_uuid": ["This field is required."] +} +``` + +اگر مزرعه برای این کاربر پیدا نشود: + +```json +{ + "farm_uuid": ["Farm not found."] +} +``` + +اگر مزرعه سنسور نداشته باشد: + +```json +{ + "farm_uuid": ["No sensor found for this farm."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +### نکات فرانت + +- این endpoint برای ساخت dashboard کامل مناسب است. +- `updatedAt` ممکن است `null` باشد. +- اگر داده واقعی هنوز وارد نشده باشد backend ممکن است fallback/mock structure برگرداند. +- برای UI بهتر است روی وجود نداشتن بعضی فیلدها defensive code داشته باشید. + +--- + +## 3) GET /api/sensors/comparison-chart/ + +### هدف + +این endpoint داده خام نمودار مقایسه‌ای را برمی‌گرداند. خروجی آن برای chart component مناسب است و wrapper `code/msg/data` ندارد. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `7d` یا `30d` | + +### نمونه درخواست + +```http +GET /api/sensors/comparison-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=7d +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "series": [ + { + "name": "moisture", + "data": [41.0, 48.5] + }, + { + "name": "temperature", + "data": [21.0, 23.2] + }, + { + "name": "ph", + "data": [6.5, 6.8] + } + ], + "categories": ["شنبه", "یکشنبه"], + "currentValue": 48.5, + "vsLastWeek": "+18.3%" +} +``` + +### توضیح فیلدها + +| فیلد | نوع | توضیح | +|---|---|---| +| `series` | `array` | تمام پارامترهای عددی موجود در payload | +| `series[].name` | `string` | نام normalized فیلد مثل `moisture`، `temperature`، `ph` | +| `series[].data` | `number[]` | نقاط سری | +| `categories` | `string[]` | برچسب‌های محور X | +| `currentValue` | `number` | آخرین مقدار سری اول | +| `vsLastWeek` | `string` | درصد تغییر آخرین مقدار نسبت به اولین مقدار سری اول | + +### رفتار `range` + +- `7d`: دسته‌بندی روی روزهای هفته فارسی انجام می‌شود. +- `30d`: برچسب‌ها به‌صورت `MM/DD` برمی‌گردند. + +### نکات مهم فرانت + +- نام سری‌ها انگلیسی و normalized هستند، نه label نمایشی. +- `currentValue` و `vsLastWeek` همیشه از سری اول محاسبه می‌شوند. +- اگر هیچ داده‌ای وجود نداشته باشد پاسخ این شکلی است: + +```json +{ + "series": [], + "categories": [], + "currentValue": 0.0, + "vsLastWeek": "+0.0%" +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"14d\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 4) GET /api/sensors/radar-chart/ + +### هدف + +این endpoint داده خام radar chart را برای سنسور مزرعه برمی‌گرداند. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `today`، `7d` یا `30d` | + +### نمونه درخواست + +```http +GET /api/sensors/radar-chart/?farm_uuid=11111111-1111-1111-1111-111111111111&range=7d +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "labels": ["Moisture", "Temperature", "PH", "EC", "Nitrogen", "Potassium"], + "series": [ + { + "name": "وضعیت فعلی", + "data": [48.5, 23.2, 6.8, 1.4, 31.0, 24.0] + }, + { + "name": "بازه ایده آل", + "data": [60.0, 26.0, 6.5, 1.3, 42.0, 38.0] + } + ] +} +``` + +### توضیح فیلدها + +| فیلد | نوع | توضیح | +|---|---|---| +| `labels` | `string[]` | محورهای radar chart | +| `series` | `array` | دو سری اصلی | +| `series[0]` | `object` | مقدار فعلی | +| `series[1]` | `object` | مقدار ایده‌آل | + +### نکات مهم فرانت + +- تعداد `labels` و طول `data` هر سری باید برابر باشد. +- فقط فیلدهایی در پاسخ می‌آیند که در آخرین payload وجود داشته باشند. +- اگر داده‌ای پیدا نشود: + +```json +{ + "labels": [], + "series": [] +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"2d\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 5) GET /api/sensors/values-list/ + +### هدف + +این endpoint لیست مقادیر current sensor و trend آن‌ها را برمی‌گرداند. مناسب برای card list، table کوتاه یا stat widgets است. + +### احراز هویت + +- نیازمند احراز هویت کاربر + +### Query Params + +| نام | نوع | اجباری | پیش‌فرض | توضیح | +|---|---|---:|---|---| +| `farm_uuid` | `uuid` | بله | - | شناسه مزرعه | +| `range` | `string` | خیر | `7d` | فقط `1h`، `24h` یا `7d` | + +### نمونه درخواست + +```http +GET /api/sensors/values-list/?farm_uuid=11111111-1111-1111-1111-111111111111&range=24h +Authorization: Bearer +``` + +### پاسخ موفق + +```json +{ + "sensors": [ + { + "title": "Moisture", + "subtitle": "مقدار فعلی: 48.5%", + "trendNumber": 7.5, + "trend": "positive", + "unit": "%" + }, + { + "title": "Temperature", + "subtitle": "مقدار فعلی: 23.2°C", + "trendNumber": 2.2, + "trend": "positive", + "unit": "°C" + }, + { + "title": "PH", + "subtitle": "مقدار فعلی: 6.8", + "trendNumber": 0.3, + "trend": "positive", + "unit": "pH" + } + ] +} +``` + +### توضیح فیلدهای هر آیتم + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | `string` | نام فیلد | +| `subtitle` | `string` | متن آماده برای نمایش مقدار فعلی | +| `trendNumber` | `number` | اختلاف آخرین مقدار نسبت به اولین مقدار در بازه | +| `trend` | `positive \| negative` | جهت تغییر | +| `unit` | `string` | واحد | + +### نکات مهم فرانت + +- `subtitle` از backend به‌صورت آماده برمی‌گردد. +- اگر در بازه انتخاب‌شده داده‌ای نباشد، backend آخرین لاگ موجود را fallback می‌کند. +- اگر هیچ لاگی وجود نداشته باشد: + +```json +{ + "sensors": [] +} +``` + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `range` نامعتبر باشد: + +```json +{ + "range": ["\"12h\" is not a valid choice."] +} +``` + +#### `401 Unauthorized` + +اگر کاربر لاگین نباشد. + +--- + +## 6) GET /api/sensor-external-api/logs/ + +### هدف + +این endpoint تاریخچه لاگ‌های ورودی سنسور خارجی را برمی‌گرداند. این API بیشتر برای: + +- history page +- debugging panel +- raw sensor log table +- trace داده‌های دریافتی از device + +مناسب است. + +### احراز هویت + +- نیازمند هدر `X-API-Key` + +نمونه: + +```http +X-API-Key: 12345 +``` + +### Query Params + +| نام | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | `uuid` | بله | شناسه مزرعه | +| `page` | `int` | بله | شماره صفحه، حداقل `1` | +| `page_size` | `int` | بله | اندازه صفحه، بین `1` تا `100` | +| `physical_device_uuid` | `uuid` | خیر | فیلتر روی دستگاه خاص | +| `sensor_type` | `string` | خیر | فیلتر روی نوع سنسور | +| `date_from` | `date` | خیر | فیلتر از تاریخ | +| `date_to` | `date` | خیر | فیلتر تا تاریخ | + +### نمونه درخواست + +```http +GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=20 +X-API-Key: 12345 +``` + +نمونه با فیلتر: + +```http +GET /api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&physical_device_uuid=55555555-5555-5555-5555-555555555555&date_from=2025-05-01&date_to=2025-05-10&page=1&page_size=20 +X-API-Key: 12345 +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "count": 2, + "next": "http://example.com/api/sensor-external-api/logs/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=1", + "previous": null, + "data": [ + { + "id": 12, + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "farm_sensor": { + "uuid": "99999999-9999-9999-9999-999999999999", + "sensor_catalog_uuid": "22222222-2222-2222-2222-222222222222", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "External device 2", + "sensor_type": "soil_sensor", + "is_active": true, + "specifications": { + "model": "FH-2" + }, + "power_source": { + "type": "solar" + }, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "sensor_catalog": { + "uuid": "22222222-2222-2222-2222-222222222222", + "code": "ext-sensor-log-2", + "name": "External Sensor Log 2", + "description": "Sensor catalog for second log", + "customizable_fields": [], + "supported_power_sources": [], + "returned_data_fields": ["humidity"], + "sample_payload": {}, + "is_active": true, + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z" + }, + "payload": { + "temp": 18 + }, + "created_at": "2025-05-02T11:00:00Z" + } + ] +} +``` + +### توضیح فیلدهای ریشه پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | `number` | کد داخلی backend | +| `msg` | `string` | پیام | +| `count` | `number` | تعداد کل آیتم‌ها قبل از pagination | +| `next` | `string \| null` | لینک صفحه بعد | +| `previous` | `string \| null` | لینک صفحه قبل | +| `data` | `array` | داده‌های صفحه فعلی | + +### توضیح هر آیتم داخل `data` + +| فیلد | نوع | توضیح | +|---|---|---| +| `id` | `number` | شناسه لاگ | +| `farm_uuid` | `string` | UUID مزرعه | +| `sensor_catalog_uuid` | `string \| null` | UUID کاتالوگ سنسور | +| `physical_device_uuid` | `string` | UUID دستگاه | +| `farm_sensor` | `object \| null` | اطلاعات سنسور مزرعه | +| `sensor_catalog` | `object \| null` | اطلاعات catalog | +| `payload` | `object` | داده خام ارسالی از device | +| `created_at` | `string` | زمان ثبت لاگ | + +### ساختار `farm_sensor` + +| فیلد | نوع | +|---|---| +| `uuid` | `string` | +| `sensor_catalog_uuid` | `string \| null` | +| `physical_device_uuid` | `string` | +| `name` | `string` | +| `sensor_type` | `string` | +| `is_active` | `boolean` | +| `specifications` | `object` | +| `power_source` | `object` | +| `created_at` | `string` | +| `updated_at` | `string` | + +### ساختار `sensor_catalog` + +| فیلد | نوع | +|---|---| +| `uuid` | `string` | +| `code` | `string` | +| `name` | `string` | +| `description` | `string` | +| `customizable_fields` | `array` | +| `supported_power_sources` | `array` | +| `returned_data_fields` | `array` | +| `sample_payload` | `object` | +| `is_active` | `boolean` | +| `created_at` | `string` | +| `updated_at` | `string` | + +### فیلترها + +#### فیلتر با `physical_device_uuid` + +فقط لاگ‌های مربوط به یک device خاص برمی‌گردد. + +#### فیلتر با `sensor_type` + +بر اساس `sensor_type` سنسور مزرعه فیلتر می‌کند. + +#### فیلتر با `date_from` و `date_to` + +بر اساس بازه تاریخ فیلتر می‌کند. + +نکته: + +- اگر هر دو ارسال شوند، `date_from` باید کوچک‌تر یا مساوی `date_to` باشد. + +### خطاهای ممکن + +#### `400 Bad Request` + +اگر `page` یا `page_size` ارسال نشود: + +```json +{ + "page": ["This field is required."], + "page_size": ["This field is required."] +} +``` + +اگر `date_from > date_to` باشد: + +```json +{ + "date_to": ["date_to must be greater than or equal to date_from."] +} +``` + +اگر `page_size > 100` باشد: + +```json +{ + "page_size": ["Ensure this value is less than or equal to 100."] +} +``` + +#### `401 Unauthorized` + +اگر API key وجود نداشته باشد: + +```json +{ + "detail": "API key is required." +} +``` + +اگر API key اشتباه باشد: + +```json +{ + "detail": "Invalid API key." +} +``` + +#### `503 Service Unavailable` + +اگر migrationهای جدول‌های لازم اجرا نشده باشند: + +```json +{ + "code": 503, + "msg": "Required tables are not ready. Run migrations." +} +``` + +### نکات فرانت + +- داده‌ها descending هستند؛ جدیدترین لاگ اول می‌آید. +- `farm_sensor` و `sensor_catalog` ممکن است `null` باشند. +- `payload` داینامیک است و ساختار ثابتی ندارد؛ UI باید generic باشد. +- برای table view بهتر است `payload` را stringify نکنید و فیلدهای مهم را extract کنید. +- برای pagination از `count`، `next` و `previous` استفاده کنید. + +--- + +## 7) تفاوت مهم بین این APIها + +| Endpoint | ساختار پاسخ | نیازمندی auth | +|---|---|---| +| `/api/sensor-7-in-1/summary/` | wrapped: `code/msg/data` | Bearer token / session | +| `/api/sensors/comparison-chart/` | raw json | Bearer token / session | +| `/api/sensors/radar-chart/` | raw json | Bearer token / session | +| `/api/sensors/values-list/` | raw json | Bearer token / session | +| `/api/sensor-external-api/logs/` | wrapped + paginated | `X-API-Key` | + +--- + +## 8) پیشنهاد برای استفاده در فرانت + +### برای dashboard اصلی + +از `GET /api/sensor-7-in-1/summary/` استفاده کنید، چون بیشتر widgetها را یکجا می‌دهد. + +### برای chartهای مستقل + +- اگر chart جداگانه و lightweight می‌خواهید از: + - `GET /api/sensors/comparison-chart/` + - `GET /api/sensors/radar-chart/` + +### برای stat card list + +- از `GET /api/sensors/values-list/` استفاده کنید. + +### برای page تاریخچه یا debug + +- از `GET /api/sensor-external-api/logs/` استفاده کنید. + +--- + +## 9) فایل‌های بک‌اند مرتبط + +- `sensor_7_in_1/views.py` +- `sensor_7_in_1/serializers.py` +- `sensor_7_in_1/services.py` +- `sensor_7_in_1/tests.py` +- `sensor_external_api/views.py` +- `sensor_external_api/serializers.py` +- `sensor_external_api/tests.py` +- `sensor_external_api/authentication.py` + diff --git a/Modules/Backend/docs/soil_frontend_api_reference.md b/Modules/Backend/docs/soil_frontend_api_reference.md new file mode 100644 index 0000000..acbe0a7 --- /dev/null +++ b/Modules/Backend/docs/soil_frontend_api_reference.md @@ -0,0 +1,381 @@ +# Soil API Reference for Frontend + +این فایل برای فرانت آماده شده تا ساختار پاسخ APIهای خاک را سریع و شفاف داشته باشید. + +## Base Notes + +- سه endpoint زیر `farm_uuid` را به صورت query param لازم دارند: + - `GET /api/soil/anomalies/` + - `GET /api/soil/moisture-heatmap/` + - `GET /api/soil/summary/` +- endpoint `GET /api/soil/avg-moisture/` بدون `farm_uuid` هم جواب می‌دهد، ولی اگر `farm_uuid` ارسال شود داده بر اساس همان مزرعه محاسبه می‌شود. +- در سه endpoint اول و سوم، اگر `farm_uuid` ارسال نشود یا مزرعه پیدا نشود، پاسخ با ساختار `code/msg/data` برمی‌گردد. +- پاسخ موفق `avg-moisture` با کلید `status` برمی‌گردد، ولی سه endpoint دیگر با کلیدهای `code`, `msg`, `data` برمی‌گردند. + +--- + +## 1) Average Soil Moisture + +### Endpoint + +```http +GET /api/soil/avg-moisture/?farm_uuid= +``` + +### Query Params + +| name | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | no | UUID مزرعه | + +### Success Response + +```json +{ + "status": "success", + "data": { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "کل مزرعه", + "stats": "65%", + "avatarColor": "primary", + "avatarIcon": "tabler-plant-2", + "chipText": "بهینه", + "chipColor": "success" + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `status` | `string` | در حالت موفق مقدار `success` | +| `data.id` | `string` | شناسه کارت | +| `data.title` | `string` | عنوان کارت | +| `data.subtitle` | `string` | زیرعنوان کارت | +| `data.stats` | `string` | مقدار اصلی به صورت درصد، مثل `48%` | +| `data.avatarColor` | `string` | رنگ آیکن/کارت | +| `data.avatarIcon` | `string` | نام آیکن | +| `data.chipText` | `string` | وضعیت متنی، مثل `بهینه`، `متوسط`، `کم` | +| `data.chipColor` | `string` | رنگ وضعیت، مثل `success`، `warning`، `error` | + +### Frontend Notes + +- این endpoint برای ساخت یک KPI card مناسب است. +- `stats` همیشه string است و بهتر است مستقیم render شود. +- `chipText` و `chipColor` برای badge یا status pill استفاده شوند. + +--- + +## 2) Soil Anomalies + +### Endpoint + +```http +GET /api/soil/anomalies/?farm_uuid= +``` + +### Query Params + +| name | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه | + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "summary": "Risk of localized soil imbalance detected.", + "explanation": "One or more soil indicators are outside the expected range.", + "likely_cause": "Uneven irrigation or nutrient distribution.", + "recommended_action": "Inspect the affected zone and verify irrigation schedule.", + "monitoring_priority": "high", + "confidence": 0.89, + "generated_at": "2025-01-01T10:30:00Z", + "anomalies": [ + { + "sensor": "رطوبت خاک زون 3", + "value": "38%", + "expected": "45-65%", + "deviation": "-12%", + "severity": "warning" + } + ], + "interpretation": { + "risk_level": "medium" + }, + "knowledge_base": null, + "raw_response": null + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `code` | `number` | در حالت موفق مقدار `200` | +| `msg` | `string` | در حالت موفق مقدار `success` | +| `data.farm_uuid` | `string` | UUID مزرعه | +| `data.summary` | `string` | خلاصه کوتاه نتیجه anomaly detection | +| `data.explanation` | `string` | توضیح readable برای فرانت | +| `data.likely_cause` | `string` | علت احتمالی | +| `data.recommended_action` | `string` | اقدام پیشنهادی | +| `data.monitoring_priority` | `string` | سطح اهمیت پایش؛ مثل `low`, `medium`, `high`, `urgent` | +| `data.confidence` | `number` | میزان اطمینان مدل | +| `data.generated_at` | `string` | زمان تولید تحلیل | +| `data.anomalies` | `array` | لیست anomalyها | +| `data.anomalies[].sensor` | `string` | نام سنسور یا ناحیه | +| `data.anomalies[].value` | `string` | مقدار فعلی | +| `data.anomalies[].expected` | `string` | بازه یا مقدار مورد انتظار | +| `data.anomalies[].deviation` | `string` | اختلاف با مقدار نرمال | +| `data.anomalies[].severity` | `string` | شدت anomaly، مثل `warning` یا `error` | +| `data.interpretation` | `object` | تفسیر ساختاریافته برای UI پیشرفته | +| `data.knowledge_base` | `string \| null` | مرجع دانشی در صورت وجود | +| `data.raw_response` | `string \| null` | متن خام upstream در صورت وجود | + +### Error Response - Missing `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### Error Response - Farm Not Found + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### Frontend Notes + +- `anomalies` می‌تواند برای table، list یا alert cards استفاده شود. +- اگر `anomalies` خالی بود، UI بهتر است empty state نمایش دهد. +- `severity` را می‌توانید به color map وصل کنید. + +--- + +## 3) Soil Moisture Heatmap + +### Endpoint + +```http +GET /api/soil/moisture-heatmap/?farm_uuid= +``` + +### Query Params + +| name | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه | + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "location": { + "name": "Zone A" + }, + "current_sensor": { + "name": "Sensor 7-in-1" + }, + "soil_profile": [], + "timestamp": "2025-01-01T10:30:00Z", + "grid_resolution": { + "rows": 10, + "cols": 10 + }, + "grid_cells": [], + "sensor_points": [], + "quality_legend": { + "low": "0-30", + "medium": "31-60", + "high": "61-100" + }, + "depth_layers": [], + "model_metadata": {}, + "summary": {} + } +} +``` + +### Supported Response Shape in Current Backend + +در serializer فعلی این فیلدها پشتیبانی می‌شوند: + +| field | type | description | +|---|---|---| +| `data.farm_uuid` | `string` | UUID مزرعه | +| `data.location` | `object` | اطلاعات مکانی | +| `data.current_sensor` | `object` | اطلاعات سنسور فعال | +| `data.soil_profile` | `array` | داده لایه‌های خاک | +| `data.timestamp` | `string \| null` | زمان تولید heatmap | +| `data.grid_resolution` | `object` | رزولوشن grid | +| `data.grid_cells` | `array` | سلول‌های grid | +| `data.sensor_points` | `array` | نقاط سنسور | +| `data.quality_legend` | `object` | legend مقادیر | +| `data.depth_layers` | `array` | لایه‌های عمقی | +| `data.model_metadata` | `object` | متادیتای مدل | +| `data.summary` | `object` | خلاصه تفسیری | + +### Legacy / Mock Shape You May Also See + +در داده mock داخلی پروژه یک ساختار ساده‌تر هم وجود دارد: + +```json +{ + "status": "success", + "data": { + "zones": ["زون 1", "زون 2"], + "hours": ["6 ص", "8 ص"], + "series": [ + { + "name": "زون 1", + "data": [ + { "x": "6 ص", "y": 52 }, + { "x": "8 ص", "y": 48 } + ] + } + ] + } +} +``` + +### Error Response - Missing `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### Error Response - Farm Not Found + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### Frontend Notes + +- چون upstream shape ممکن است object-based یا series-based باشد، فرانت بهتر است defensive parsing داشته باشد. +- اگر `grid_cells` وجود داشت، heatmap را از grid render کنید. +- اگر `series` وجود داشت، می‌توانید آن را به chart heatmap یا matrix chart بدهید. + +--- + +## 4) Soil Summary + +### Endpoint + +```http +GET /api/soil/summary/?farm_uuid= +``` + +### Query Params + +| name | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه | + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "healthScore": 82, + "profileSource": "Tomato", + "healthScoreDetails": {}, + "healthLanguage": {}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 46.0, + "avgSoilMoistureStatus": "بهینه" + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `code` | `number` | در حالت موفق مقدار `200` | +| `msg` | `string` | در حالت موفق مقدار `success` | +| `data.farm_uuid` | `string` | UUID مزرعه | +| `data.healthScore` | `number` | امتیاز سلامت کلی خاک | +| `data.profileSource` | `string` | منبع پروفایل یا محصول مرجع | +| `data.healthScoreDetails` | `object` | جزئیات محاسبه health score | +| `data.healthLanguage` | `object` | متن‌ها و labelهای قابل نمایش | +| `data.avgSoilMoisture` | `number` | میانگین گرد شده رطوبت خاک | +| `data.avgSoilMoistureRaw` | `number` | میانگین خام | +| `data.avgSoilMoistureStatus` | `string` | وضعیت متنی رطوبت خاک | + +### Error Response - Missing `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### Error Response - Farm Not Found + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### Frontend Notes + +- این endpoint برای summary card یا hero panel خیلی مناسب است. +- `healthScoreDetails` و `healthLanguage` را optional در نظر بگیرید. +- برای UI بهتر، `healthScore` را هم به صورت عدد و هم به صورت progress/gauge نمایش دهید. + +--- + +## Suggested Frontend Handling + +- برای `avg-moisture` انتظار `status/data` داشته باشید. +- برای `anomalies`, `moisture-heatmap`, `summary` انتظار `code/msg/data` داشته باشید. +- برای خطاهای 400 و 404، متن خطا را از `data.farm_uuid[0]` بخوانید. +- در heatmap، parsing را flexible بنویسید چون shape داده ممکن است بسته به upstream تغییر کند. diff --git a/Modules/Backend/docs/water_weather_frontend_api_reference.md b/Modules/Backend/docs/water_weather_frontend_api_reference.md new file mode 100644 index 0000000..90d9e62 --- /dev/null +++ b/Modules/Backend/docs/water_weather_frontend_api_reference.md @@ -0,0 +1,381 @@ +# Water & Weather API Reference for Frontend + +این فایل برای فرانت آماده شده تا ساختار پاسخ APIهای زیر مشخص باشد: + +- `GET /api/water/card/` +- `GET /api/water/need-prediction/` +- `GET /api/water/summary/` +- `POST /api/weather/farm-card/` + +## Base Notes + +- `GET /api/water/card/` و `GET /api/water/summary/` بدون `farm_uuid` هم جواب می‌دهند. +- `GET /api/water/need-prediction/` هم بدون `farm_uuid` جواب می‌دهد، ولی اگر `farm_uuid` وجود داشته باشد ممکن است داده مزرعه‌محور برگردد. +- `POST /api/weather/farm-card/` نیاز به `farm_uuid` در body دارد. +- response shapeها بین این endpointها یکدست نیستند: + - بعضی endpointها با `status/data` + - بعضی endpointها با `code/msg/data` + +--- + +## 1) Water Card + +### Endpoint + +```http +GET /api/water/card/?farm_uuid= +``` + +### Query Params + +| field | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | no | UUID مزرعه | + +### Success Response + +```json +{ + "status": "success", + "data": { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر"], + "series": [[18, 22, 26, 28]] + } + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `status` | `string` | در حالت موفق مقدار `success` | +| `data.condition` | `string` | وضعیت فعلی آب‌وهوا | +| `data.temperature` | `number` | دمای فعلی | +| `data.unit` | `string` | واحد دما | +| `data.humidity` | `number` | رطوبت نسبی | +| `data.windSpeed` | `number` | سرعت باد | +| `data.windUnit` | `string` | واحد سرعت باد | +| `data.chartData.labels` | `string[]` | برچسب‌های زمانی | +| `data.chartData.series` | `number[][]` | سری‌های نمودار | + +### Frontend Notes + +- این endpoint برای weather widget یا weather card مناسب است. +- `chartData.series` به شکل آرایه دوبعدی است. +- اگر `farm_uuid` ارسال شود، backend داده را از AI گرفته و log هم می‌کند. + +--- + +## 2) Water Need Prediction + +### Endpoint + +```http +GET /api/water/need-prediction/?farm_uuid= +``` + +### Query Params + +| field | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | no | UUID مزرعه | + +### Success Response + +```json +{ + "status": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "totalNext7Days": 24.6, + "unit": "mm", + "categories": ["2025-01-01", "2025-01-02", "2025-01-03"], + "series": [ + { + "name": "نیاز آبی", + "data": [3.2, 4.1, 2.8] + } + ], + "dailyBreakdown": [], + "insight": {}, + "knowledge_base": "", + "raw_response": "" + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `status` | `string` | در حالت موفق مقدار `success` | +| `data.farm_uuid` | `string` | UUID مزرعه در حالت farm-based | +| `data.totalNext7Days` | `number` | مجموع نیاز آبی 7 روز آینده | +| `data.unit` | `string` | واحد نیاز آبی، مثل `mm` یا `m3` | +| `data.categories` | `string[]` | روزها یا تاریخ‌ها | +| `data.series` | `Array<{name: string, data: number[]}>` | داده‌های نمودار | +| `data.dailyBreakdown` | `object[]` | breakdown روزانه در صورت وجود | +| `data.insight` | `object` | insight یا خلاصه تحلیلی | +| `data.knowledge_base` | `string` | منبع دانشی در صورت وجود | +| `data.raw_response` | `string` | پاسخ خام upstream در صورت وجود | + +### Frontend Notes + +- اگر `farm_uuid` معتبر باشد، backend داده را از AI می‌گیرد. +- اگر `farm_uuid` ارسال نشود، backend از داده داخلی/mock استفاده می‌کند. +- اگر `farm_uuid` ارسال شود ولی مزرعه پیدا نشود، فعلاً به fallback داخلی برمی‌گردد و خطا نمی‌دهد. + +--- + +## 3) Water Summary + +### Endpoint + +```http +GET /api/water/summary/?farm_uuid= +``` + +### Query Params + +| field | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | no | UUID مزرعه | + +### Success Response + +```json +{ + "status": "success", + "data": { + "farmWeatherCard": { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر"], + "series": [[18, 22, 26]] + } + }, + "waterNeedPrediction": { + "totalNext7Days": 3290, + "unit": "m3", + "categories": ["روز 1", "روز 2", "روز 3"], + "series": [ + { + "name": "نیاز آبی", + "data": [420, 450, 480] + } + ] + }, + "waterStressIndex": { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": "12%", + "avatarColor": "info", + "avatarIcon": "tabler-droplet", + "chipText": "پایین", + "chipColor": "success" + } + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `status` | `string` | در حالت موفق مقدار `success` | +| `data.farmWeatherCard` | `object` | اطلاعات کارت وضعیت آب‌وهوا | +| `data.waterNeedPrediction` | `object` | پیش‌بینی نیاز آبی | +| `data.waterStressIndex` | `object` | کارت شاخص تنش آبی | + +### `waterStressIndex` Fields + +| field | type | description | +|---|---|---| +| `id` | `string` | شناسه کارت | +| `title` | `string` | عنوان کارت | +| `subtitle` | `string` | زیرعنوان | +| `stats` | `string` | مقدار اصلی برای نمایش | +| `avatarColor` | `string` | رنگ کارت/آیکن | +| `avatarIcon` | `string` | نام آیکن | +| `chipText` | `string` | وضعیت متنی | +| `chipColor` | `string` | رنگ وضعیت | + +### Frontend Notes + +- این endpoint برای dashboard overview مناسب است. +- سه بخش اصلی summary را می‌توانید مستقیم به سه widget مختلف map کنید. +- `waterSummary` یک response ترکیبی است و برای یک صفحه dashboard خیلی کاربردی است. + +--- + +## 4) Weather Farm Card + +### Endpoint + +```http +POST /api/weather/farm-card/ +``` + +### Request Body + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### Request Fields + +| field | type | required | description | +|---|---|---:|---| +| `farm_uuid` | `string (uuid)` | yes | UUID مزرعه | + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "condition": "صاف", + "temperature": 28.0, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر"], + "series": [[18, 22, 26, 28]] + } + } +} +``` + +### Response Fields + +| field | type | description | +|---|---|---| +| `code` | `number` | در حالت موفق مقدار `200` | +| `msg` | `string` | در حالت موفق مقدار `success` | +| `data.condition` | `string` | وضعیت فعلی آب‌وهوا | +| `data.temperature` | `number` | دمای فعلی | +| `data.unit` | `string` | واحد دما | +| `data.humidity` | `number` | رطوبت نسبی | +| `data.windSpeed` | `number` | سرعت باد | +| `data.windUnit` | `string` | واحد سرعت باد | +| `data.chartData.labels` | `string[]` | برچسب‌های زمانی | +| `data.chartData.series` | `number[][]` | داده‌های نمودار | + +### Error Response - Missing `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### Error Response - Farm Not Found + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### Error Response - Upstream Error + +```json +{ + "code": 500, + "msg": "error", + "data": { + "message": "Upstream service error" + } +} +``` + +### Frontend Notes + +- این endpoint نسخه authenticated و farm-specific برای weather card است. +- اگر farm متعلق به کاربر فعلی نباشد، `404` برمی‌گردد. +- response این endpoint با `code/msg/data` است، نه `status/data`. + +--- + +## Suggested TypeScript Interfaces + +```ts +export interface WeatherChartData { + labels?: string[]; + series?: number[][]; +} + +export interface FarmWeatherCard { + condition?: string; + temperature?: number; + unit?: string; + humidity?: number; + windSpeed?: number; + windUnit?: string; + chartData?: WeatherChartData; +} + +export interface WaterNeedSeries { + name?: string; + data?: number[]; +} + +export interface WaterNeedPrediction { + farm_uuid?: string; + totalNext7Days?: number; + unit?: string; + categories?: string[]; + series?: WaterNeedSeries[]; + dailyBreakdown?: Record[]; + insight?: Record; + knowledge_base?: string; + raw_response?: string; +} + +export interface WaterStressCard { + id?: string; + title?: string; + subtitle?: string; + stats?: string; + avatarColor?: string; + avatarIcon?: string; + chipText?: string; + chipColor?: string; +} +``` + +--- + +## Suggested Frontend Handling + +- برای `GET /api/water/card/`, `GET /api/water/need-prediction/`, `GET /api/water/summary/` انتظار `status/data` داشته باشید. +- برای `POST /api/weather/farm-card/` انتظار `code/msg/data` داشته باشید. +- برای `POST /api/weather/farm-card/` خطاها را از `data.farm_uuid[0]` بخوانید. +- برای chartها بهتر است `labels` و `series` را optional render کنید. diff --git a/Modules/Backend/docs/yield_harvest_ai_integration.md b/Modules/Backend/docs/yield_harvest_ai_integration.md new file mode 100644 index 0000000..544e809 --- /dev/null +++ b/Modules/Backend/docs/yield_harvest_ai_integration.md @@ -0,0 +1,941 @@ +# مرجع کامل ارتباط Backend با AI در ماژول Yield & Harvest + +این سند قرارداد فعلی backend برای endpointهای ماژول `yield_harvest` را توضیح می‌دهد؛ هم از دید فرانت/کاربر، هم از دید payload ارسالی به سرویس AI. + +این سند این endpointها را پوشش می‌دهد: + +- `POST /api/yield-harvest/current-farm-chart/` +- `POST /api/yield-harvest/growth/` +- `GET /api/yield-harvest/growth/{task_id}/status/` +- `POST /api/yield-harvest/harvest-prediction/` +- `POST /api/yield-harvest/yield-prediction/` +- `GET /api/yield-harvest/yield-harvest-summary/` + +--- + +## هدف این سند + +این ماژول باید برای endpointهای farm-based تا حد ممکن فقط `farm_uuid` را از کاربر بگیرد و بقیه context لازم را خودش از دیتابیس بخواند. + +مهم‌ترین قاعده این سند: + +- فرانت نباید `plant_name` را برای endpointهای farm-based ارسال کند. +- backend باید `plant_name` را از `farm_hub.models.FarmHub` استخراج کند. +- منبع استخراج `plant_name` این است: + 1. اولین محصول `farm.products` بر اساس `id` + 2. اگر مزرعه محصول نداشت، اولین محصول `farm.farm_type.products` بر اساس `id` + +پیاده‌سازی فعلی این رفتار در فایل زیر است: + +- `yield_harvest/views.py` + +مدل‌های منبع داده: + +- `farm_hub/models.py` + +--- + +## احراز هویت و سطح دسترسی + +همه endpointهای این سند نیاز به JWT معتبر دارند. + +### هدرهای متداول + +```http +Authorization: Bearer +Content-Type: application/json +Accept: application/json +``` + +### اعتبارسنجی مالکیت مزرعه + +برای endpointهایی که `farm_uuid` می‌گیرند، backend فقط زمانی درخواست را قبول می‌کند که: + +- مزرعه وجود داشته باشد +- و مالک آن مزرعه همان `request.user` باشد + +اگر مزرعه برای کاربر جاری پیدا نشود: + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +--- + +## الگوی کلی پاسخ‌ها + +تقریباً تمام endpointهای این ماژول از envelope زیر استفاده می‌کنند: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +### معنی فیلدهای envelope + +| فیلد | نوع | توضیح | +|---|---|---| +| `code` | integer | کد منطقی پاسخ؛ معمولاً با HTTP status هم‌راستا است | +| `msg` | string | پیام کوتاه پاسخ | +| `data` | object / array / null | بدنه اصلی پاسخ | + +### خطاهای متداول + +| HTTP Status | `code` | توضیح | +|---|---|---| +| `400` | `400` | ورودی نامعتبر است | +| `404` | `404` | مزرعه برای کاربر جاری پیدا نشد | +| `500` | `500` | AI یا لایه محاسباتی upstream خطا داده است | +| `202` | `202` | تسک async با موفقیت در صف قرار گرفته است | + +--- + +## قرارداد ورودی از دید Frontend + +### اصل طراحی + +برای endpointهای farm-based این ماژول، فرانت فقط باید `farm_uuid` را ارسال کند و نباید موارد زیر را از کاربر بگیرد: + +- `plant_name` +- `crop_name` برای جریان‌های farm-based prediction +- هر context دیگری که backend می‌تواند از مزرعه استخراج کند + +### استثناها + +- `GET /api/yield-harvest/growth/{task_id}/status/` به `farm_uuid` نیاز ندارد؛ چون بر اساس `task_id` کار می‌کند. +- `GET /api/yield-harvest/yield-harvest-summary/` علاوه بر `farm_uuid` می‌تواند queryهای اختیاری هم داشته باشد، ولی در قرارداد فرانت ساده می‌توان فقط `farm_uuid` را فرستاد. +- endpoint رشد (`growth`) در لایه AI پارامترهای پیشرفته دارد، اما در قرارداد ساده frontend این سند، ورودی کاربر باید فقط `farm_uuid` باشد و backend باید context گیاه را از مزرعه بردارد. + +--- + +## نگاشت endpointهای Backend به AI + +| Backend Route | Method | AI Route | Method | +|---|---|---|---| +| `/api/yield-harvest/current-farm-chart/` | `POST` | `/api/crop-simulation/current-farm-chart/` | `POST` | +| `/api/yield-harvest/growth/` | `POST` | `/api/crop-simulation/growth/` | `POST` | +| `/api/yield-harvest/growth/{task_id}/status/` | `GET` | `/api/crop-simulation/growth/{task_id}/status/` | `GET` | +| `/api/yield-harvest/harvest-prediction/` | `POST` | `/api/crop-simulation/harvest-prediction/` | `POST` | +| `/api/yield-harvest/yield-prediction/` | `POST` | `/api/crop-simulation/yield-prediction/` | `POST` | +| `/api/yield-harvest/yield-harvest-summary/` | `GET` | `/api/crop-simulation/yield-harvest-summary/` | `GET` | + +--- + +## منبع `plant_name` در Backend + +### منبع داده + +backend نام گیاه را از مدل `FarmHub` در `farm_hub/models.py` می‌گیرد. + +### ترتیب انتخاب + +1. `farm.products.order_by("id").first()` +2. اگر مورد 1 خالی بود: `farm.farm_type.products.order_by("id").first()` + +### مثال مفهومی + +اگر مزرعه این محصولات را داشته باشد: + +```text +farm.products = ["خیار", "گوجه‌فرنگی"] +``` + +backend این مقدار را برای AI می‌فرستد: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "خیار" +} +``` + +یعنی معیار فعلی، «اولین محصول بر اساس `id`» است، نه محصول انتخاب‌شده توسط کاربر. + +--- + +## 1) POST `/api/yield-harvest/current-farm-chart/` + +### کاربرد + +دریافت نمودار وضعیت فعلی مزرعه بر اساس شبیه‌سازی رشد محصول. + +### ورودی از فرانت + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### نکته مهم + +- `plant_name` از کاربر گرفته نمی‌شود. +- backend آن را از مزرعه استخراج می‌کند. + +### payload ارسالی backend به AI + +نمونه: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "خیار" +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": 12, + "simulation_warning": null, + "categories": ["2026-04-01", "2026-04-02"], + "series": [ + { + "name": "تعداد برگ تخمینی", + "key": "leaf_count_estimate", + "data": [120.0, 140.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": [] + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string / null | شناسه مزرعه | +| `plant_name` | string | نام گیاهی که شبیه‌سازی برای آن انجام شده | +| `engine` | string / null | موتور شبیه‌سازی | +| `model_name` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار غیر بحرانی | +| `categories` | array[string] | محور زمانی نمودار | +| `series` | array[object] | سری‌های نمودار | +| `summary` | array[object] | کارت‌های خلاصه | +| `current_state` | object | وضعیت آخرین روز شبیه‌سازی | +| `metrics` | object | شاخص‌های محاسبه‌شده | +| `daily_output` | array[object] | خروجی خام روزانه | + +### توضیح `series[]` + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | عنوان سری | +| `key` | string | کلید فنی سری | +| `data` | array[number] | مقادیر سری | + +### توضیح `summary[]` + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | زیرعنوان | +| `amount` | number | مقدار اصلی | +| `unit` | string | واحد | +| `avatarColor` | string | رنگ پیشنهادی UI | +| `avatarIcon` | string | آیکن پیشنهادی UI | + +### توضیح `current_state` + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ آخرین رکورد | +| `leaf_count_estimate` | number | تعداد برگ تخمینی | +| `leaf_area_index` | number | شاخص سطح برگ | +| `biomass_weight` | number | وزن بیوماس | +| `storage_organ_weight` | number | وزن اندام ذخیره‌ای / محصول | +| `soil_moisture_percent` | number | درصد رطوبت خاک | +| `development_stage` | number | مرحله رشد | +| `gdd` | number | درجه-روز رشد | + +--- + +## 2) POST `/api/yield-harvest/growth/` + +### کاربرد + +شروع شبیه‌سازی رشد به صورت async. + +### قرارداد ساده فرانت + +در قرارداد frontend این سند، فرانت فقط باید `farm_uuid` را بفرستد. + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### نکته مهم + +- `plant_name` نباید از کاربر گرفته شود. +- backend آن را از مزرعه استخراج می‌کند. +- `task_id` خروجی این endpoint، ورودی endpoint وضعیت است. + +### payload ارسالی backend به AI + +نمونه مفهومی: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "خیار", + "dynamic_parameters": ["DVS", "LAI", "TAGP", "TWSO", "SM"] +} +``` + +نکته: upstream AI ممکن است پارامترهای پیشرفته بیشتری هم بپذیرد، ولی این‌ها نباید از کاربر نهایی گرفته شوند مگر این‌که قرارداد جداگانه‌ای برای expert mode تعریف شود. + +### پاسخ موفق + +```json +{ + "code": 202, + "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", + "data": { + "task_id": "growth-task-1", + "status_url": "/api/crop-simulation/growth/growth-task-1/status/", + "plant_name": "گوجه‌فرنگی" + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `task_id` | string | شناسه تسک | +| `status_url` | string | آدرس بررسی وضعیت تسک | +| `plant_name` | string | نام گیاهی که شبیه‌سازی برای آن آغاز شده | + +--- + +## 3) GET `/api/yield-harvest/growth/{task_id}/status/` + +### کاربرد + +بررسی وضعیت و نتیجه تسک async شبیه‌سازی رشد. + +### ورودی + +این endpoint از کاربر `farm_uuid` نمی‌گیرد. + +### Path Parameter + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `task_id` | string | بله | شناسه تسک برگشتی از endpoint رشد | + +### Query اختیاری + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `page` | integer | خیر | شماره صفحه stageها | +| `page_size` | integer | خیر | تعداد آیتم در هر صفحه | + +### پاسخ در حالت `PENDING` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} +``` + +### پاسخ در حالت `PROGRESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "PROGRESS", + "progress": { + "current": 2, + "total": 3, + "percent": 66.7 + } + } +} +``` + +### پاسخ در حالت `SUCCESS` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "SUCCESS", + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS"], + "engine": "growth_projection", + "model_name": "growth_projection_v1", + "scenario_id": null, + "simulation_warning": null, + "summary_metrics": {}, + "stage_timeline": [], + "stages_page": [], + "pagination": { + "page": 1, + "page_size": 10, + "total_items": 0, + "total_pages": 0, + "has_next": false, + "has_previous": false + }, + "daily_records_count": 0, + "default_page_size": 10 + } + } +} +``` + +### پاسخ در حالت `FAILURE` + +```json +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "growth-task-1", + "status": "FAILURE", + "error": "task crashed" + } +} +``` + +### توضیح فیلدهای status response + +| فیلد | نوع | توضیح | +|---|---|---| +| `task_id` | string | شناسه تسک | +| `status` | string | وضعیت تسک: `PENDING`, `PROGRESS`, `SUCCESS`, `FAILURE` | +| `message` | string | پیام کمکی در برخی وضعیت‌ها | +| `progress` | object | وضعیت پیشرفت | +| `result` | object | نتیجه نهایی در حالت موفق | +| `error` | string | خطای نهایی در حالت failure | + +### توضیح `progress` + +| فیلد | نوع | توضیح | +|---|---|---| +| `current` | integer | مرحله فعلی | +| `total` | integer | کل مراحل | +| `percent` | float | درصد پیشرفت | + +### توضیح `result` + +| فیلد | نوع | توضیح | +|---|---|---| +| `plant_name` | string | نام گیاه | +| `dynamic_parameters` | array[string] | پارامترهای دینامیک | +| `engine` | string / null | موتور شبیه‌سازی | +| `model_name` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار محاسباتی | +| `summary_metrics` | object | شاخص‌های خلاصه | +| `stage_timeline` | array[object] | timeline کامل مراحل | +| `stages_page` | array[object] | آیتم‌های همین صفحه | +| `pagination` | object | اطلاعات صفحه‌بندی | +| `daily_records_count` | integer | تعداد رکوردهای روزانه | +| `default_page_size` | integer | اندازه صفحه پیش‌فرض | + +### توضیح `pagination` + +| فیلد | نوع | توضیح | +|---|---|---| +| `page` | integer | صفحه فعلی | +| `page_size` | integer | اندازه صفحه | +| `total_items` | integer | تعداد کل stageها | +| `total_pages` | integer | تعداد کل صفحه‌ها | +| `has_next` | boolean | آیا صفحه بعدی وجود دارد | +| `has_previous` | boolean | آیا صفحه قبلی وجود دارد | + +--- + +## 4) POST `/api/yield-harvest/harvest-prediction/` + +### کاربرد + +پیش‌بینی زمان برداشت برای مزرعه. + +### ورودی از فرانت + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "خیار" +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "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" + } + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `date` | string | تاریخ تخمینی برداشت به فرمت ISO | +| `dateFormatted` | string | تاریخ قابل نمایش | +| `daysUntil` | integer | تعداد روزهای باقیمانده | +| `description` | string | توضیح متنی | +| `optimalWindowStart` | string | شروع پنجره مناسب برداشت | +| `optimalWindowEnd` | string | پایان پنجره مناسب برداشت | +| `gddDetails` | object | جزئیات محاسبات GDD | + +### توضیح `gddDetails` + +| فیلد | نوع | توضیح | +|---|---|---| +| `current_cumulative_gdd` | number | GDD تجمعی فعلی | +| `required_gdd_for_maturity` | number | GDD مورد نیاز برای بلوغ | +| `remaining_gdd` | number | GDD باقی‌مانده | +| `estimated_days_to_harvest` | integer | روزهای برآوردی تا برداشت | +| `predicted_harvest_date` | string | تاریخ برآوردی برداشت | +| `predicted_harvest_window` | object | بازه برداشت | +| `daily_gdd_forecast` | array[object] | پیش‌بینی روزانه GDD | +| `simulation_engine` | string | موتور شبیه‌سازی | +| `simulation_model_name` | string | نام مدل | +| `simulation_warning` | string / null | هشدار محاسباتی | +| `scenario_id` | integer / null | شناسه سناریو | + +--- + +## 5) POST `/api/yield-harvest/yield-prediction/` + +### کاربرد + +پیش‌بینی عملکرد مزرعه. + +### ورودی از فرانت + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "خیار" +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "predictedYieldTons": 5.4, + "predictedYieldRaw": 5400.0, + "unit": "تن", + "sourceUnit": "kg/ha", + "simulationEngine": "growth_projection", + "simulationModel": "growth_projection_v1", + "scenarioId": 12, + "simulationWarning": null, + "supportingMetrics": { + "yield_estimate": 5400.0 + } + } +} +``` + +### توضیح فیلدهای پاسخ + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `plant_name` | string / null | نام گیاه | +| `predictedYieldTons` | number | عملکرد بر حسب تن | +| `predictedYieldRaw` | number | مقدار خام عملکرد | +| `unit` | string | واحد نمایشی | +| `sourceUnit` | string | واحد منبع | +| `simulationEngine` | string / null | موتور شبیه‌سازی | +| `simulationModel` | string / null | نام مدل شبیه‌سازی | +| `scenarioId` | integer / null | شناسه سناریو | +| `simulationWarning` | string / null | هشدار محاسباتی | +| `supportingMetrics` | object | شاخص‌های پشتیبان | + +### توضیح `supportingMetrics` + +این object بسته به upstream می‌تواند شامل مواردی مانند این‌ها باشد: + +| فیلد | نوع | توضیح | +|---|---|---| +| `yield_estimate` | number | برآورد خام عملکرد | +| `biomass` | number | بیوماس برآوردی | +| `max_lai` | number | بیشترین شاخص سطح برگ | + +--- + +## 6) GET `/api/yield-harvest/yield-harvest-summary/` + +### کاربرد + +دریافت داشبورد کامل عملکرد و برداشت. + +### ورودی ساده از فرانت + +```http +GET /api/yield-harvest/yield-harvest-summary/?farm_uuid=11111111-1111-1111-1111-111111111111 +``` + +### Queryهای اختیاری قابل پشتیبانی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---:|---| +| `farm_uuid` | UUID | بله | شناسه مزرعه | +| `season_year` | integer | خیر | سال زراعی | +| `crop_name` | string | خیر | نام محصول | +| `include_narrative` | boolean | خیر | در صورت `true` متن‌های توضیحی هم merge می‌شوند | + +### نکته قرارداد فرانت + +در جریان ساده frontend، ارسال فقط `farm_uuid` کافی است و backend بقیه context لازم را مدیریت می‌کند. + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "season_highlights_card": {}, + "yield_prediction": {}, + "harvest_prediction_card": {}, + "harvest_readiness_zones": {}, + "yield_quality_bands": {}, + "harvest_operations_card": {}, + "yield_prediction_chart": {} + } +} +``` + +### توضیح top-level response + +| فیلد | نوع | توضیح | +|---|---|---| +| `farm_uuid` | string | شناسه مزرعه | +| `season_highlights_card` | object | خلاصه مهم‌ترین KPIها | +| `yield_prediction` | object | خروجی پیش‌بینی عملکرد | +| `harvest_prediction_card` | object | تاریخ و وضعیت برداشت | +| `harvest_readiness_zones` | object | آمادگی برداشت در zoneها | +| `yield_quality_bands` | object | کیفیت برآوردی محصول | +| `harvest_operations_card` | object | عملیات پیشنهادی برداشت | +| `yield_prediction_chart` | object | نمودار عملکرد و بیوماس | + +### توضیح `season_highlights_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `title` | string | عنوان کارت | +| `subtitle` | string | توضیح کوتاه | +| `total_predicted_yield` | number / null | عملکرد پیش‌بینی‌شده | +| `yield_unit` | string | واحد عملکرد | +| `target_harvest_date` | string / null | تاریخ هدف برداشت | +| `days_until_harvest` | integer / null | روز باقی‌مانده | +| `average_readiness` | number / null | میانگین آمادگی | +| `primary_quality_grade` | string / null | گرید کیفیت غالب | +| `estimated_revenue` | number / null | درآمد تخمینی | +| `soil_type` | string / null | نوع خاک | + +### توضیح `yield_prediction` + +| فیلد | نوع | توضیح | +|---|---|---| +| `predicted_yield_tons` | number | عملکرد بر حسب تن | +| `predicted_yield_raw` | number | عملکرد خام | +| `unit` | string | واحد نمایشی | +| `source_unit` | string | واحد منبع | +| `simulation_engine` | string / null | موتور شبیه‌سازی | +| `simulation_model` | string / null | نام مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه‌سازی | +| `secondary_kpis_estimated` | boolean | آیا KPIهای ثانویه تخمینی‌اند | +| `descriptionSource` | string | منبع توضیح | +| `farm_context` | object | context مزرعه | +| `supporting_metrics` | object | متریک‌های پشتیبان | +| `explanation` | string | توضیح متنی | + +### توضیح `harvest_prediction_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `harvest_date` | string | تاریخ ISO برداشت | +| `harvest_date_formatted` | string | تاریخ قابل نمایش | +| `days_until` | integer | روز باقی‌مانده | +| `optimal_window_start` | string | شروع بازه مناسب | +| `optimal_window_end` | string | پایان بازه مناسب | +| `description` | string | توضیح متنی | +| `descriptionSource` | string | منبع توضیح | +| `field_conditions` | object | شرایط فعلی مزرعه | +| `readiness_metrics` | object | جزئیات readiness/GDD | + +### توضیح `harvest_readiness_zones` + +| فیلد | نوع | توضیح | +|---|---|---| +| `observationDate` | string / null | تاریخ مشاهده | +| `vegetationHealthClass` | string / null | کلاس سلامت پوشش گیاهی | +| `meanNdvi` | number / null | NDVI میانگین | +| `ndviTrend` | number / null | روند NDVI | +| `averageReadiness` | number / null | میانگین آمادگی | +| `zones` | array[object] | فهرست zoneها | +| `source` | string | منبع داده | +| `summary` | string | توضیح خلاصه | + +### توضیح هر zone در `zones[]` + +| فیلد | نوع | توضیح | +|---|---|---| +| `zoneId` | string | شناسه zone | +| `zoneLabel` | string | نام نمایشی zone | +| `gridPosition` | object / null | موقعیت grid | +| `meanNdvi` | number | NDVI میانگین zone | +| `readiness` | integer | درصد آمادگی | +| `daysUntil` | integer | روز باقی‌مانده | +| `status` | string | وضعیت zone | + +### توضیح `yield_quality_bands` + +| فیلد | نوع | توضیح | +|---|---|---| +| `source` | string | منبع محاسبه | +| `is_estimated` | boolean | آیا مقادیر تخمینی‌اند | +| `protein_content` | object | درصد پروتئین | +| `moisture_percentage` | object | درصد رطوبت | +| `grade_distribution` | array[object] | توزیع گریدها | +| `primary_quality_grade` | string | گرید غالب | +| `quality_score` | number | امتیاز کیفیت | +| `summary` | string | خلاصه متنی | + +### توضیح `harvest_operations_card` + +| فیلد | نوع | توضیح | +|---|---|---| +| `stage_label` | string | برچسب مرحله عملیاتی | +| `phase_name` | string | نام فاز رشد | +| `days_until_harvest` | integer | روز باقی‌مانده | +| `current_dvs` | number | DVS فعلی | +| `summary` | string | خلاصه عملیاتی | +| `rules_source` | string | منبع قواعد | +| `field_context` | object | context مزرعه | +| `steps` | array[object] | گام‌های عملیاتی | + +### توضیح هر step در `steps[]` + +| فیلد | نوع | توضیح | +|---|---|---| +| `key` | string | کلید فنی step | +| `title` | string | عنوان عملیات | +| `status` | string | وضعیت step | +| `is_completed` | boolean | آیا انجام شده | +| `estimated_days` | integer | روز برآوردی | +| `note` | string | توضیح تکمیلی | + +### توضیح `yield_prediction_chart` + +| فیلد | نوع | توضیح | +|---|---|---| +| `series` | array[object] | سری‌های نمودار | +| `xAxis` | object | تنظیمات محور افقی | +| `meta` | object | متادیتای نمودار | + +### توضیح `yield_prediction_chart.series[]` + +| فیلد | نوع | توضیح | +|---|---|---| +| `name` | string | نام سری | +| `type` | string | نوع رسم مانند `line` یا `area` | +| `data` | array[[timestamp, value]] | داده‌های نمودار؛ timestamp بر حسب milliseconds | + +### توضیح `yield_prediction_chart.meta` + +| فیلد | نوع | توضیح | +|---|---|---| +| `unit` | string | واحد داده | +| `simulation_engine` | string | موتور شبیه‌سازی | +| `simulation_model` | string | مدل | +| `scenario_id` | integer / null | شناسه سناریو | +| `simulation_warning` | string / null | هشدار شبیه‌سازی | +| `field_context` | object | context مزرعه | + +--- + +## خطاهای رایج با مثال + +### نبودن `farm_uuid` + +```json +{ + "code": 400, + "msg": "error", + "data": { + "farm_uuid": ["This field is required."] + } +} +``` + +### پیدا نشدن مزرعه برای کاربر جاری + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": ["Farm not found."] + } +} +``` + +### خطای upstream AI + +```json +{ + "code": 500, + "msg": "error", + "data": { + "code": 500, + "msg": "خطا در پیش بینی عملکرد: Plant not found.", + "data": null + } +} +``` + +نکته: در این وضعیت، envelope بیرونی از backend آمده و object داخلی معمولاً همان پاسخ upstream AI است. + +--- + +## پیش‌فرض Swagger + +برای endpointهای body-based این ماژول، `farm_uuid` در Swagger با مقدار پیش‌فرض زیر نمایش داده می‌شود: + +```text +11111111-1111-1111-1111-111111111111 +``` + +این رفتار برای endpointهای زیر برقرار است: + +- `POST /api/yield-harvest/current-farm-chart/` +- `POST /api/yield-harvest/growth/` +- `POST /api/yield-harvest/harvest-prediction/` +- `POST /api/yield-harvest/yield-prediction/` + +--- + +## جمع‌بندی اجرایی برای فرانت + +### چیزی که فرانت باید بفرستد + +- برای بیشتر endpointها فقط `farm_uuid` +- برای status فقط `task_id` +- در جریان ساده، `plant_name` هرگز از کاربر گرفته نشود + +### چیزی که backend خودش مدیریت می‌کند + +- پیدا کردن مزرعه متعلق به کاربر +- استخراج `plant_name` از `farm.products` یا `farm.farm_type.products` +- ارسال payload مناسب به AI +- normalize کردن پاسخ AI در envelope استاندارد backend + +### چیزی که فرانت نباید به کاربر بسپارد + +- انتخاب دستی `plant_name` در این flow +- ساخت payload مستقیم AI +- تفسیر business ruleهای انتخاب محصول + +--- + +## مسیر فایل + +این سند در مسیر زیر نگهداری می‌شود: + +`docs/yield_harvest_ai_integration.md` diff --git a/Modules/Backend/docs/yield_harvest_prediction_api_changes.md b/Modules/Backend/docs/yield_harvest_prediction_api_changes.md new file mode 100644 index 0000000..3adaa58 --- /dev/null +++ b/Modules/Backend/docs/yield_harvest_prediction_api_changes.md @@ -0,0 +1,269 @@ +# Yield/Harvest Prediction API Changes + +این فایل تغییرات 3 API زیر را توضیح می‌دهد: + +- `POST /api/yield-harvest/harvest-prediction/` +- `POST /api/yield-harvest/yield-prediction/` +- `POST /api/yield-harvest/current-farm-chart/` + +--- + +## خلاصه تغییرات + +تغییر اصلی در هر 3 endpoint این است که backend حالا context موردنیاز AI را خودش از روی مزرعه و planهای انتخابی می‌سازد. + +### قبل + +در استفاده قدیمی، معمولاً فرض می‌شد client باید context بیشتری برای AI بفرستد. + +### الآن + +- `farm_uuid` ورودی اصلی و الزامی است. +- `plant_name` اگر هم توسط client ارسال شود، مبنای نهایی backend نیست و از روی مزرعه بازنویسی/resolve می‌شود. +- در صورت نیاز، `irrigation_plan_uuid` و `fertilization_plan_uuid` هم می‌توانند ارسال شوند. +- اگر plan انتخابی معتبر و متعلق به همان مزرعه کاربر باشد، backend محتوای آن را به payload ارسالی به AI اضافه می‌کند. +- خروجی backend به‌صورت یکدست با فرمت `code / msg / data` برگردانده می‌شود. + +--- + +## Request Contract جدید + +هر 3 API از این قرارداد ورودی استفاده می‌کنند: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "fertilization_plan_uuid": "7e7b2f1e-2b0c-4a3a-9fe2-3e84e0e3e0a2" +} +``` + +### فیلدها + +- `farm_uuid` اجباری +- `irrigation_plan_uuid` اختیاری +- `fertilization_plan_uuid` اختیاری + +### نکته مهم + +اگر client `plant_name` بفرستد، در این APIها مبنای نهایی backend نیست؛ backend نام گیاه را از مزرعه استخراج می‌کند. + +--- + +## 1) POST `/api/yield-harvest/current-farm-chart/` + +### تغییرات + +- ورودی endpoint عملاً بر پایه `farm_uuid` کار می‌کند و `plant_name` از context مزرعه تعیین می‌شود. +- backend به‌صورت خودکار `plant_name` را از مزرعه پیدا می‌کند. +- در صورت ارسال `irrigation_plan_uuid`، اطلاعات برنامه آبیاری داخل payload ارسالی به AI قرار می‌گیرد. +- در صورت ارسال `fertilization_plan_uuid`، اطلاعات برنامه کودی هم اضافه می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111" +} +``` + +### payload ارسالی backend به AI + +نمونه مفهومی: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی" +} +``` + +### در صورت انتخاب plan + +نمونه مفهومی: + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_plan": { + "id": 12, + "plan_payload": { + "plan": { + "durationMinutes": 20 + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "scenario_id": 1, + "categories": ["day1"], + "series": { + "biomass": [1.2] + } + } +} +``` + +--- + +## 2) POST `/api/yield-harvest/harvest-prediction/` + +### تغییرات + +- ورودی endpoint عملاً بر پایه `farm_uuid` کار می‌کند و `plant_name` توسط backend تعیین می‌شود. +- امکان ارسال `fertilization_plan_uuid` و `irrigation_plan_uuid` برای enrich کردن context اضافه/پشتیبانی شده است. +- پاسخ AI بعد از extract شدن در `data.result`، به شکل مستقیم در `data` برگردانده می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "fertilization_plan_uuid": "7e7b2f1e-2b0c-4a3a-9fe2-3e84e0e3e0a2" +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "fertilization_plan": { + "id": 34, + "plan_payload": { + "primary_recommendation": { + "fertilizer_code": "npk-151515" + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "gddDetails": { + "current": 800 + } + } +} +``` + +--- + +## 3) POST `/api/yield-harvest/yield-prediction/` + +### تغییرات + +- مثل دو endpoint دیگر، `plant_name` از روی مزرعه resolve می‌شود. +- در نبود محصول مستقیم روی مزرعه، backend از fallback مناسب مزرعه استفاده می‌کند. +- امکان ارسال `irrigation_plan_uuid` و `fertilization_plan_uuid` برای فرستادن context planها به AI اضافه/پشتیبانی شده است. +- پاسخ نهایی با ساختار یکنواخت `code / msg / data` برگردانده می‌شود. + +### نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "irrigation_plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "fertilization_plan_uuid": "7e7b2f1e-2b0c-4a3a-9fe2-3e84e0e3e0a2" +} +``` + +### payload ارسالی backend به AI + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه‌فرنگی", + "irrigation_plan": { + "id": 12, + "plan_payload": { + "plan": { + "durationMinutes": 30 + } + } + }, + "fertilization_plan": { + "id": 34, + "plan_payload": { + "primary_recommendation": { + "fertilizer_code": "npk-202020" + } + } + } +} +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "predictedYieldTons": 8.4, + "scenarioId": 1 + } +} +``` + +--- + +## خطاها و Validation + +### 1) مزرعه نامعتبر یا متعلق به کاربر دیگر + +در این حالت endpoint خطای دسترسی/یافت‌نشدن مزرعه برمی‌گرداند. + +### 2) plan نامعتبر یا متعلق به مزرعه دیگر + +اگر `irrigation_plan_uuid` یا `fertilization_plan_uuid` متعلق به همان مزرعه کاربر نباشد، درخواست با خطا رد می‌شود. + +نمونه: + +```json +{ + "code": 404, + "msg": "error", + "data": { + "irrigation_plan_uuid": [ + "Irrigation plan not found." + ] + } +} +``` + +### 3) خطای validation ورودی + +اگر `farm_uuid` ارسال نشود یا `plan_uuid`ها نامعتبر باشند، serializer خطای validation برمی‌گرداند. + +--- + +## جمع‌بندی تغییرات برای فرانت + +- دیگر لازم نیست `plant_name` را برای این 3 API بفرستید. +- فقط `farm_uuid` اجباری است. +- اگر کاربر plan خاصی را انتخاب کرده، `irrigation_plan_uuid` و/یا `fertilization_plan_uuid` را هم بفرستید. +- response هر 3 endpoint با ساختار یکنواخت `code`, `msg`, `data` برمی‌گردد. +- backend خودش payload مناسب AI را از context مزرعه و planهای انتخابی می‌سازد. diff --git a/Modules/Backend/economic_overview/__init__.py b/Modules/Backend/economic_overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/economic_overview/apps.py b/Modules/Backend/economic_overview/apps.py new file mode 100644 index 0000000..a46447b --- /dev/null +++ b/Modules/Backend/economic_overview/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class EconomicOverviewConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "economic_overview" + verbose_name = "Economic Overview" diff --git a/Modules/Backend/economic_overview/defaults.py b/Modules/Backend/economic_overview/defaults.py new file mode 100644 index 0000000..6b94f17 --- /dev/null +++ b/Modules/Backend/economic_overview/defaults.py @@ -0,0 +1,8 @@ +EMPTY_ECONOMIC_OVERVIEW = { + "economicData": [], + "chartSeries": [], + "chartCategories": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted economic overview data is available for this farm."], +} diff --git a/Modules/Backend/economic_overview/migrations/0001_initial.py b/Modules/Backend/economic_overview/migrations/0001_initial.py new file mode 100644 index 0000000..afc6aa0 --- /dev/null +++ b/Modules/Backend/economic_overview/migrations/0001_initial.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("farm_hub", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="EconomicOverviewLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ("farm", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="economic_overview_logs", to="farm_hub.farmhub")), + ("economic_data", models.JSONField(blank=True, default=list)), + ("chart_series", models.JSONField(blank=True, default=list)), + ("chart_categories", models.JSONField(blank=True, default=list)), + ("fetched_at", models.DateTimeField(auto_now_add=True)), + ], + options={"db_table": "economic_overview_logs", "ordering": ["-fetched_at"]}, + ), + ] diff --git a/Modules/Backend/economic_overview/migrations/__init__.py b/Modules/Backend/economic_overview/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/economic_overview/mock_data.py b/Modules/Backend/economic_overview/mock_data.py new file mode 100644 index 0000000..0e4f857 --- /dev/null +++ b/Modules/Backend/economic_overview/mock_data.py @@ -0,0 +1,37 @@ +ECONOMIC_OVERVIEW = { + "economicData": [ + { + "title": "هزینه آب", + "value": "€720", + "subtitle": "این ماه", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "صرفه‌جویی آب هوشمند", + "value": "€156", + "subtitle": "۱۸٪ صرفه‌جویی شده", + "avatarIcon": "tabler-bulb", + "avatarColor": "success", + }, + { + "title": "بازده سرمایه پلتفرم", + "value": "127%", + "subtitle": "نسبت به سال گذشته", + "avatarIcon": "tabler-chart-line", + "avatarColor": "info", + }, + { + "title": "پیش‌بینی درآمد", + "value": "€42k", + "subtitle": "این فصل", + "avatarIcon": "tabler-currency-euro", + "avatarColor": "success", + }, + ], + "chartSeries": [ + {"name": "هزینه آب", "data": [120, 115, 110, 125, 118, 122]}, + {"name": "کود", "data": [80, 85, 90, 75, 82, 78]}, + ], + "chartCategories": ["ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن"], +} diff --git a/Modules/Backend/economic_overview/models.py b/Modules/Backend/economic_overview/models.py new file mode 100644 index 0000000..d612961 --- /dev/null +++ b/Modules/Backend/economic_overview/models.py @@ -0,0 +1,28 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub + + +class EconomicOverviewLog(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="economic_overview_logs", + null=True, + blank=True, + ) + economic_data = models.JSONField(default=list, blank=True) + chart_series = models.JSONField(default=list, blank=True) + chart_categories = models.JSONField(default=list, blank=True) + fetched_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "economic_overview_logs" + ordering = ["-fetched_at"] + + def __str__(self): + farm_label = str(self.farm_id) if self.farm_id else "no-farm" + return f"{farm_label} — {self.fetched_at}" diff --git a/Modules/Backend/economic_overview/serializers.py b/Modules/Backend/economic_overview/serializers.py new file mode 100644 index 0000000..2f495b7 --- /dev/null +++ b/Modules/Backend/economic_overview/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers + + +class EconomicDataItemSerializer(serializers.Serializer): + title = serializers.CharField(help_text="عنوان شاخص اقتصادی.") + value = serializers.CharField(help_text="مقدار شاخص اقتصادی.") + subtitle = serializers.CharField(help_text="توضیح تکمیلی شاخص.") + avatarIcon = serializers.CharField(help_text="آیکون نمایشی شاخص.") + avatarColor = serializers.CharField(help_text="رنگ نمایشی شاخص.") + + +class ChartSeriesSerializer(serializers.Serializer): + name = serializers.CharField() + data = serializers.ListField(child=serializers.FloatField()) + + +class EconomicOverviewSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + source = serializers.CharField(required=False, allow_blank=True, help_text="منبع داده یا نوع تولید پاسخ.") + economicData = EconomicDataItemSerializer(many=True) + chartSeries = ChartSeriesSerializer(many=True) + chartCategories = serializers.ListField(child=serializers.CharField(), help_text="برچسب‌های محور افقی نمودار اقتصادی.") + + +class EconomicOverviewRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت نمای اقتصادی.") diff --git a/Modules/Backend/economic_overview/services.py b/Modules/Backend/economic_overview/services.py new file mode 100644 index 0000000..1f53f21 --- /dev/null +++ b/Modules/Backend/economic_overview/services.py @@ -0,0 +1,27 @@ +from copy import deepcopy + +from .defaults import EMPTY_ECONOMIC_OVERVIEW +from .models import EconomicOverviewLog + + +def get_economic_overview_data(farm=None): + data = deepcopy(EMPTY_ECONOMIC_OVERVIEW) + + if farm is None: + return data + + log = EconomicOverviewLog.objects.filter(farm=farm).first() + if log is None: + return data + + data["status"] = "success" + data["source"] = "db" + data["warnings"] = [] + if log.economic_data: + data["economicData"] = deepcopy(log.economic_data) + if log.chart_series: + data["chartSeries"] = deepcopy(log.chart_series) + if log.chart_categories: + data["chartCategories"] = deepcopy(log.chart_categories) + + return data diff --git a/Modules/Backend/economic_overview/tests.py b/Modules/Backend/economic_overview/tests.py new file mode 100644 index 0000000..c5d7148 --- /dev/null +++ b/Modules/Backend/economic_overview/tests.py @@ -0,0 +1,97 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import EconomyOverviewView + + +class EconomyOverviewViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("economic_overview.views.external_api_request") + def test_overview_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "source": "mock", + "economicData": [{"title": "Revenue", "value": "10"}], + "chartSeries": [{"name": "Revenue", "data": [1.0, 2.0]}], + "chartCategories": ["فروردین", "اردیبهشت"], + } + } + }, + ) + + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["source"], "mock") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/economy/overview/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_overview_rejects_foreign_farm_uuid(self): + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + + def test_economy_routes_exist_only_under_economy_prefix(self): + self.assertIs(resolve("/api/economy/overview/").func.view_class, EconomyOverviewView) + + with self.assertRaises(Resolver404): + resolve("/api/economy/summary/") + + with self.assertRaises(Resolver404): + resolve("/api/economic-overview/summary/") + + @patch("economic_overview.views.external_api_request") + def test_overview_returns_structured_502_for_invalid_upstream_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": []}, + ) + + request = self.factory.post("/api/economy/overview/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = EconomyOverviewView.as_view()(request) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.data["data"]["error_code"], "invalid_payload") + self.assertEqual(response.data["data"]["source"], "ai_provider") diff --git a/Modules/Backend/economic_overview/urls.py b/Modules/Backend/economic_overview/urls.py new file mode 100644 index 0000000..c04f3d4 --- /dev/null +++ b/Modules/Backend/economic_overview/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import EconomyOverviewView + +urlpatterns = [ + path("overview/", EconomyOverviewView.as_view(), name="economy-overview"), +] diff --git a/Modules/Backend/economic_overview/views.py b/Modules/Backend/economic_overview/views.py new file mode 100644 index 0000000..8a326ee --- /dev/null +++ b/Modules/Backend/economic_overview/views.py @@ -0,0 +1,143 @@ +import logging + +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.failure_contract import StructuredServiceError +from config.swagger import status_response +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError +from farm_hub.models import FarmHub +from .models import EconomicOverviewLog +from .serializers import EconomicOverviewRequestSerializer, EconomicOverviewSerializer + +logger = logging.getLogger(__name__) + + +class EconomicOverviewAdapterError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, source: str, retriable: bool = False, details: dict | None = None): + super().__init__( + error_code=error_code, + message=message, + source=source, + retriable=retriable, + details=details, + ) + + +class EconomyOverviewView(APIView): + @staticmethod + def _extract_result_or_error(adapter_data): + if not isinstance(adapter_data, dict): + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview adapter returned a non-object payload.", + source="ai_provider", + ) + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview adapter payload did not contain structured result data.", + source="ai_provider", + ) + + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _persist_log(farm, overview_data): + if not isinstance(overview_data, dict): + raise EconomicOverviewAdapterError( + error_code="invalid_payload", + message="Economic overview data must be a JSON object before persistence.", + source="backend", + ) + EconomicOverviewLog.objects.create( + farm=farm, + economic_data=overview_data.get("economicData", []), + chart_series=overview_data.get("chartSeries", []), + chart_categories=overview_data.get("chartCategories", []), + ) + + @extend_schema( + tags=["Economy"], + request=EconomicOverviewRequestSerializer, + responses={200: status_response("EconomyOverviewResponse", data=EconomicOverviewSerializer())}, + ) + def post(self, request): + serializer = EconomicOverviewRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm, error_response = self._get_farm(request, serializer.validated_data["farm_uuid"]) + if error_response is not None: + return error_response + + payload = {"farm_uuid": str(farm.farm_uuid)} + try: + adapter_response = external_api_request( + "ai", + "/api/economy/overview/", + method="POST", + payload=payload, + ) + except ExternalAPIRequestError as exc: + logger.error("Economic overview upstream request failed for farm_uuid=%s: %s", farm.farm_uuid, exc) + failure = EconomicOverviewAdapterError( + error_code="upstream_unavailable", + message="Economic overview upstream request failed.", + source="ai_provider", + retriable=True, + details={"farm_uuid": str(farm.farm_uuid)}, + ) + return Response( + {"code": 503, "msg": "error", "data": failure.to_dict()}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + try: + overview_data = self._extract_result_or_error(adapter_response.data) + if isinstance(overview_data, dict): + overview_data.setdefault("farm_uuid", str(farm.farm_uuid)) + self._persist_log(farm, overview_data) + except EconomicOverviewAdapterError as exc: + logger.error("Economic overview payload handling failed for farm_uuid=%s: %s", farm.farm_uuid, exc) + return Response( + {"code": 502, "msg": "error", "data": exc.to_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response({"code": 200, "msg": "success", "data": overview_data}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/entrypoint.sh b/Modules/Backend/entrypoint.sh new file mode 100644 index 0000000..2fda445 --- /dev/null +++ b/Modules/Backend/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e +if [ "${SKIP_MIGRATE}" != "1" ]; then + echo "Running migrations..." + python manage.py migrate --noinput --fake-initial + echo "Migrations done." + if [ "${DOCKER_VERSION}" = "develop" ]; then + echo "Running develop seeders..." + python manage.py seed_admin_farm + python manage.py seed_sensor_7_in_1 + echo "Develop seeders done." + fi +fi +echo "Starting command: $*" +exec "$@" diff --git a/Modules/Backend/external_api_adapter/README.md b/Modules/Backend/external_api_adapter/README.md new file mode 100644 index 0000000..a16d5ef --- /dev/null +++ b/Modules/Backend/external_api_adapter/README.md @@ -0,0 +1,33 @@ +# External API Adapter + +## Settings + +```python +USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true" + +EXTERNAL_SERVICES = { + "ai": { + "base_url": os.getenv("AI_SERVICE_BASE_URL", ""), + "api_key": os.getenv("AI_SERVICE_API_KEY", ""), + }, + "farm_hub": { + "base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""), + "api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""), + }, +} +``` + +## Usage + +```python +from rest_framework.response import Response +from rest_framework.views import APIView + +from external_api_adapter import request + + +class PredictionProxyView(APIView): + def get(self, request_obj): + adapter_response = request("ai", "/predict") + return Response(adapter_response.data, status=adapter_response.status_code) +``` diff --git a/Modules/Backend/external_api_adapter/__init__.py b/Modules/Backend/external_api_adapter/__init__.py new file mode 100644 index 0000000..7270034 --- /dev/null +++ b/Modules/Backend/external_api_adapter/__init__.py @@ -0,0 +1,3 @@ +from .adapter import ExternalAPIAdapter, request + +__all__ = ["ExternalAPIAdapter", "request"] diff --git a/Modules/Backend/external_api_adapter/adapter.py b/Modules/Backend/external_api_adapter/adapter.py new file mode 100644 index 0000000..35cde4e --- /dev/null +++ b/Modules/Backend/external_api_adapter/adapter.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass, field +import json +import logging + +import requests +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder + +from .exceptions import ExternalAPIRequestError +from .exceptions import MockDirectoryNotFound, MockFileNotFound +from .mock_loader import MockLoader +from .services import ServiceRegistry + + +logger = logging.getLogger(__name__) + + +@dataclass +class AdapterResponse: + status_code: int + data: object + headers: dict = field(default_factory=dict) + is_mock: bool = False + + +class ExternalAPIAdapter: + def __init__(self, service_registry=None, mock_loader=None): + self.service_registry = service_registry or ServiceRegistry() + self.mock_loader = mock_loader or MockLoader() + + def request(self, service_name, path, method="GET", payload=None, query=None, headers=None): + request_method = method.upper() + self._validate_method(request_method) + service = self.service_registry.get(service_name) + logger.warning( + "External API adapter request start: service=%s method=%s path=%s payload_type=%s payload_keys=%s query_keys=%s header_keys=%s", + service_name, + request_method, + path, + type(payload).__name__, + sorted(payload.keys()) if isinstance(payload, dict) else None, + sorted(query.keys()) if isinstance(query, dict) else None, + sorted(headers.keys()) if isinstance(headers, dict) else None, + ) + + use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai" + if use_mock: + try: + mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method) + return AdapterResponse( + status_code=mock_response.status_code, + data=mock_response.data, + headers={"X-Mock-File": mock_response.file_path}, + is_mock=True, + ) + except (MockDirectoryNotFound, MockFileNotFound): + pass + + return self._call_real_api( + service=service, + path=path, + method=request_method, + payload=payload, + query=query, + headers=headers, + ) + + def _call_real_api(self, service, path, method, payload=None, query=None, headers=None): + base_url = service.get("base_url", "").rstrip("/") + api_key = service.get("api_key", "") + host_header = service.get("host_header", "").strip() + if not base_url: + raise ExternalAPIRequestError("External service base_url is not configured.") + url = f"{base_url}/{str(path).lstrip('/')}" + + files = None + request_payload = self._make_json_safe(payload) + request_query = self._make_json_safe(query) + request_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + if host_header: + request_headers["Host"] = host_header + if headers: + request_headers.update(headers) + + if isinstance(payload, dict) and payload.get("__files__"): + files = payload["__files__"] + request_payload = { + key: value + for key, value in payload.items() + if key != "__files__" + } + request_headers.pop("Content-Type", None) + + try: + request_kwargs = { + "method": method, + "url": url, + "params": request_query, + "headers": request_headers, + "timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30), + } + if files: + request_kwargs["data"] = request_payload + request_kwargs["files"] = files + else: + request_kwargs["json"] = request_payload + + logger.warning( + "External API adapter outbound request: method=%s url=%s has_files=%s json_keys=%s data_keys=%s timeout=%s", + method, + url, + bool(files), + sorted(request_payload.keys()) if isinstance(request_payload, dict) and not files else None, + sorted(request_payload.keys()) if isinstance(request_payload, dict) and files else None, + request_kwargs["timeout"], + ) + + response = requests.request( + **request_kwargs, + ) + except requests.RequestException as exc: + raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc + + try: + response_data = response.json() + except ValueError: + response_data = response.text + + logger.warning( + "External API adapter inbound response: method=%s url=%s status_code=%s response_type=%s response_keys=%s text_length=%s", + method, + url, + response.status_code, + type(response_data).__name__, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(response_data) if isinstance(response_data, str) else None, + ) + logger.warning("Response : %s",response_data) + + return AdapterResponse( + status_code=response.status_code, + data=response_data, + headers=dict(response.headers), + is_mock=False, + ) + + @staticmethod + def _validate_method(method): + supported_methods = {"GET", "POST", "PUT", "DELETE"} + if method not in supported_methods: + raise ValueError(f"Unsupported HTTP method '{method}'. Supported methods: {sorted(supported_methods)}") + + @staticmethod + def _make_json_safe(value): + if value is None: + return None + + # Match Django/DRF JSON rendering so UUID/date-like values can be forwarded safely. + return json.loads(json.dumps(value, cls=DjangoJSONEncoder)) + + +_default_adapter = ExternalAPIAdapter() + + +def request(service_name, path, method="GET", payload=None, query=None, headers=None): + return _default_adapter.request( + service_name=service_name, + path=path, + method=method, + payload=payload, + query=query, + headers=headers, + ) diff --git a/Modules/Backend/external_api_adapter/apps.py b/Modules/Backend/external_api_adapter/apps.py new file mode 100644 index 0000000..f1d6802 --- /dev/null +++ b/Modules/Backend/external_api_adapter/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ExternalApiAdapterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "external_api_adapter" + verbose_name = "External API Adapter" diff --git a/Modules/Backend/external_api_adapter/exceptions.py b/Modules/Backend/external_api_adapter/exceptions.py new file mode 100644 index 0000000..3a5b109 --- /dev/null +++ b/Modules/Backend/external_api_adapter/exceptions.py @@ -0,0 +1,18 @@ +class ExternalAPIAdapterError(Exception): + pass + + +class ServiceNotFound(ExternalAPIAdapterError): + pass + + +class MockDirectoryNotFound(ExternalAPIAdapterError): + pass + + +class MockFileNotFound(ExternalAPIAdapterError): + pass + + +class ExternalAPIRequestError(ExternalAPIAdapterError): + pass diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_202.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_202.json new file mode 100644 index 0000000..34b0f71 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_202.json @@ -0,0 +1,8 @@ +{ + "code": 202, + "msg": "dashboard task queued", + "data": { + "task_id": "dashboard-task-123", + "status_url": "/api/dashboard-data/dashboard-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_400.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_400.json new file mode 100644 index 0000000..5df03b8 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/generate/post_400.json @@ -0,0 +1,5 @@ +{ + "code": 400, + "msg": "پارامتر sensor_id الزامی است.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_failure.json new file mode 100644 index 0000000..bc4f2eb --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "dashboard-task-123", + "status": "FAILURE", + "error": "خطا در ساخت کارت‌های داشبورد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_pending.json new file mode 100644 index 0000000..74dafc6 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "dashboard-task-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_progress.json new file mode 100644 index 0000000..e3515e5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_progress.json @@ -0,0 +1,14 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "dashboard-task-123", + "status": "PROGRESS", + "progress": { + "current": 5, + "total": 15, + "card": "sensorValuesList", + "message": "processing sensorValuesList" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json new file mode 100644 index 0000000..fef804e --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/dashboard-data/status/get_200_success.json @@ -0,0 +1,611 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "dashboard-task-123", + "status": "SUCCESS", + "result": { + "sensor_id": "550e8400-e29b-41d4-a716-446655440000", + "all_cards": { + "farmOverviewKpis": { + "kpis": [ + { + "id": "farm_health_score", + "title": "امتیاز سلامت مزرعه", + "subtitle": "تحلیل هوشمند", + "stats": "87%", + "avatarColor": "success", + "avatarIcon": "tabler-heartbeat", + "chipText": "خوب", + "chipColor": "success" + }, + { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": "12%", + "avatarColor": "info", + "avatarIcon": "tabler-droplet", + "chipText": "پایین", + "chipColor": "success" + }, + { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "۷ روز اخیر", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-bug", + "chipText": "5%", + "chipColor": "success" + }, + { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "کل مزرعه", + "stats": "65%", + "avatarColor": "primary", + "avatarIcon": "tabler-plant-2", + "chipText": "بهینه", + "chipColor": "success" + }, + { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": "42 تن", + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "+8%", + "chipColor": "success" + }, + { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش‌بینی هوشمند", + "stats": "15%", + "avatarColor": "warning", + "avatarIcon": "tabler-bug-off", + "chipText": "تحت نظر", + "chipColor": "warning" + } + ] + }, + "farmWeatherCard": { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": [ + "۶ صبح", + "۹ صبح", + "۱۲ ظهر", + "۳ بعدازظهر", + "۶ عصر", + "۹ شب", + "۱۲ شب" + ], + "series": [ + [ + 18, + 22, + 26, + 28, + 25, + 20, + 18 + ] + ] + } + }, + "farmAlertsTracker": { + "totalAlerts": 3, + "radialBarValue": 30, + "alertStats": [ + { + "title": "کمبود آب", + "count": "2", + "avatarColor": "error", + "avatarIcon": "tabler-droplet-half-2" + }, + { + "title": "ریسک قارچی", + "count": "1", + "avatarColor": "warning", + "avatarIcon": "tabler-mushroom" + }, + { + "title": "هشدار یخبندان", + "count": "0", + "avatarColor": "info", + "avatarIcon": "tabler-snowflake" + } + ] + }, + "sensorValuesList": { + "sensors": [ + { + "title": "28°C", + "subtitle": "دمای هوا", + "trendNumber": 2.1, + "trend": "positive", + "unit": "°C" + }, + { + "title": "24°C", + "subtitle": "دمای خاک", + "trendNumber": -0.5, + "trend": "negative", + "unit": "°C" + }, + { + "title": "65%", + "subtitle": "رطوبت هوا", + "trendNumber": 3.2, + "trend": "positive", + "unit": "%" + }, + { + "title": "42%", + "subtitle": "رطوبت خاک (۱۰ سانتی‌متر)", + "trendNumber": -1.8, + "trend": "negative", + "unit": "%" + }, + { + "title": "6.8", + "subtitle": "pH خاک", + "trendNumber": 0.2, + "trend": "positive", + "unit": "pH" + }, + { + "title": "1.2", + "subtitle": "هدایت الکتریکی (dS/m)", + "trendNumber": 0.1, + "trend": "positive", + "unit": "dS/m" + }, + { + "title": "850", + "subtitle": "شدت نور (لوکس)", + "trendNumber": 15.3, + "trend": "positive", + "unit": "lux" + }, + { + "title": "12", + "subtitle": "سرعت باد (کیلومتر/ساعت)", + "trendNumber": -2.4, + "trend": "negative", + "unit": "km/h" + } + ] + }, + "sensorRadarChart": { + "labels": [ + "دما", + "رطوبت", + "pH", + "هدایت الکتریکی", + "نور", + "باد" + ], + "series": [ + { + "name": "امروز", + "data": [ + 75, + 65, + 80, + 70, + 85, + 60 + ] + }, + { + "name": "ایده‌آل", + "data": [ + 80, + 70, + 75, + 75, + 90, + 50 + ] + } + ] + }, + "sensorComparisonChart": { + "currentValue": 48, + "vsLastWeek": "+5%", + "vsLastWeekValue": 5, + "categories": [ + "دوشنبه", + "سه‌شنبه", + "چهارشنبه", + "پنج‌شنبه", + "جمعه", + "شنبه", + "یکشنبه" + ], + "series": [ + { + "name": "امروز", + "data": [ + 42, + 45, + 48, + 52, + 50, + 48, + 46 + ] + }, + { + "name": "هفته قبل", + "data": [ + 38, + 40, + 42, + 45, + 43, + 40, + 38 + ] + } + ] + }, + "anomalyDetectionCard": { + "anomalies": [ + { + "sensor": "رطوبت خاک زون ۳", + "value": "38%", + "expected": "45-65%", + "deviation": "-12%", + "severity": "warning" + }, + { + "sensor": "pH بخش ۲", + "value": "5.2", + "expected": "6.0-7.0", + "deviation": "-0.8", + "severity": "error" + } + ] + }, + "farmAlertsTimeline": { + "alerts": [ + { + "title": "ریسک کمبود آب", + "description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.", + "time": "۱۵ دقیقه پیش", + "color": "warning" + }, + { + "title": "ریسک بیماری قارچی", + "description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.", + "time": "۱ ساعت پیش", + "color": "error" + }, + { + "title": "پیشنهاد آبیاری", + "description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.", + "time": "۲ ساعت پیش", + "color": "info" + }, + { + "title": "بررسی شوری خاک", + "description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.", + "time": "۴ ساعت پیش", + "color": "success" + } + ] + }, + "waterNeedPrediction": { + "totalNext7Days": 3290, + "unit": "m³", + "categories": [ + "روز ۱", + "روز ۲", + "روز ۳", + "روز ۴", + "روز ۵", + "روز ۶", + "روز ۷" + ], + "series": [ + { + "name": "نیاز آبی", + "data": [ + 420, + 450, + 480, + 460, + 490, + 510, + 480 + ] + } + ] + }, + "harvestPredictionCard": { + "date": "2025-10-15", + "dateFormatted": "۱۵ اکتبر ۲۰۲۵", + "daysUntil": 58, + "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", + "optimalWindowStart": "2025-10-12", + "optimalWindowEnd": "2025-10-18" + }, + "yieldPredictionChart": { + "categories": [ + "ژانویه", + "فوریه", + "مارس", + "آوریل", + "می", + "ژوئن", + "ژوئیه", + "آگوست", + "سپتامبر", + "اکتبر", + "نوامبر", + "دسامبر" + ], + "series": [ + { + "name": "امسال", + "data": [ + 35, + 38, + 40, + 42, + 45, + 48, + 50, + 48, + 46, + 44, + 42, + 42 + ] + }, + { + "name": "سال گذشته", + "data": [ + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 42, + 40, + 38, + 36, + 38 + ] + } + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": "42 تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar" + }, + { + "title": "تاریخ برداشت", + "subtitle": "حدود ۱۵ اکتبر", + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar" + } + ] + }, + "soilMoistureHeatmap": { + "zones": [ + "زون ۱", + "زون ۲", + "زون ۳", + "زون ۴", + "زون ۵", + "زون ۶", + "زون ۷" + ], + "hours": [ + "۶ ص", + "۸ ص", + "۱۰ ص", + "۱۲ ظ", + "۱۴ ع", + "۱۶ ع", + "۱۸ ع" + ], + "series": [ + { + "name": "زون ۱", + "data": [ + { + "x": "۶ ص", + "y": 52 + }, + { + "x": "۸ ص", + "y": 48 + }, + { + "x": "۱۰ ص", + "y": 55 + }, + { + "x": "۱۲ ظ", + "y": 60 + }, + { + "x": "۱۴ ع", + "y": 58 + }, + { + "x": "۱۶ ع", + "y": 54 + }, + { + "x": "۱۸ ع", + "y": 50 + } + ] + }, + { + "name": "زون ۲", + "data": [ + { + "x": "۶ ص", + "y": 45 + }, + { + "x": "۸ ص", + "y": 42 + }, + { + "x": "۱۰ ص", + "y": 48 + }, + { + "x": "۱۲ ظ", + "y": 52 + }, + { + "x": "۱۴ ع", + "y": 50 + }, + { + "x": "۱۶ ع", + "y": 47 + }, + { + "x": "۱۸ ع", + "y": 44 + } + ] + } + ] + }, + "ndviHealthCard": { + "ndviIndex": 0.78, + "healthData": [ + { + "title": "تنش نیتروژن", + "value": "پایین", + "color": "success", + "icon": "tabler-leaf" + }, + { + "title": "سلامت محصول", + "value": "خوب", + "color": "success", + "icon": "tabler-plant" + } + ] + }, + "recommendationsList": { + "recommendations": [ + { + "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح", + "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary" + }, + { + "title": "کود: NPK 20-20-20", + "subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success" + }, + { + "title": "قارچ‌کش: پیشگیرانه", + "subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.", + "avatarIcon": "tabler-mushroom", + "avatarColor": "warning" + }, + { + "title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر", + "subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.", + "avatarIcon": "tabler-calendar-event", + "avatarColor": "info" + } + ] + }, + "economicOverview": { + "economicData": [ + { + "title": "هزینه آب", + "value": "€720", + "subtitle": "این ماه", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary" + }, + { + "title": "صرفه‌جویی آب هوشمند", + "value": "€156", + "subtitle": "۱۸٪ صرفه‌جویی شده", + "avatarIcon": "tabler-bulb", + "avatarColor": "success" + }, + { + "title": "بازده سرمایه پلتفرم", + "value": "127%", + "subtitle": "نسبت به سال گذشته", + "avatarIcon": "tabler-chart-line", + "avatarColor": "info" + }, + { + "title": "پیش‌بینی درآمد", + "value": "€42k", + "subtitle": "این فصل", + "avatarIcon": "tabler-currency-euro", + "avatarColor": "success" + } + ], + "chartSeries": [ + { + "name": "هزینه آب", + "data": [ + 120, + 115, + 110, + 125, + 118, + 122 + ] + }, + { + "name": "کود", + "data": [ + 80, + 85, + 90, + 75, + 82, + 78 + ] + } + ], + "chartCategories": [ + "ژانویه", + "فوریه", + "مارس", + "آوریل", + "می", + "ژوئن" + ] + } + } + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_202.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_202.json new file mode 100644 index 0000000..e8dc23f --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_202.json @@ -0,0 +1,8 @@ +{ + "code": 202, + "msg": "تسک توصیه کودهی در صف قرار گرفت.", + "data": { + "task_id": "fert-task-123", + "status_url": "/api/fertilization/recommend/fert-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_400.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_400.json new file mode 100644 index 0000000..5e4c3f5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/recommend/post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_failure.json new file mode 100644 index 0000000..fb8bad0 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "fert-task-123", + "status": "FAILURE", + "error": "خطا در دریافت توصیه کودهی." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_pending.json new file mode 100644 index 0000000..110be1a --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "fert-task-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_progress.json new file mode 100644 index 0000000..ffdf909 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_progress.json @@ -0,0 +1,11 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "fert-task-123", + "status": "PROGRESS", + "progress": { + "message": "در حال پردازش توصیه کودهی..." + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_success.json new file mode 100644 index 0000000..7b46ba1 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/fertilization/status/get_200_success.json @@ -0,0 +1,19 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "fert-task-123", + "status": "SUCCESS", + "result": { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "کودآبیاری در دو نوبت", + "applicationInterval": "هر ۱۰ روز", + "reasoning": "نیتروژن و پتاسیم خاک در محدوده متوسط است و گیاه در فاز رویشی نیاز تغذیه‌ای بالاتری دارد." + }, + "raw_response": "{\"plan\":{\"npkRatio\":\"20-20-20\"}}", + "status": "completed" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/index.json b/Modules/Backend/external_api_adapter/json/ai/index.json new file mode 100644 index 0000000..7fa834f --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/index.json @@ -0,0 +1,892 @@ +[ + { + "method": "POST", + "path": "/api/dashboard-data/generate/", + "status_code": 202, + "description": "Dashboard data task queued", + "file": "json/mock_data/dashboard-data/generate/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/dashboard-data/generate/", + "status_code": 400, + "description": "Missing sensor_id", + "file": "json/mock_data/dashboard-data/generate/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/dashboard-data/{task_id}/status/", + "status_code": 200, + "description": "Pending dashboard task", + "file": "json/mock_data/dashboard-data/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/dashboard-data/{task_id}/status/", + "status_code": 200, + "description": "Dashboard task in progress", + "file": "json/mock_data/dashboard-data/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/dashboard-data/{task_id}/status/", + "status_code": 200, + "description": "Successful dashboard task", + "file": "json/mock_data/dashboard-data/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/dashboard-data/{task_id}/status/", + "status_code": 200, + "description": "Failed dashboard task", + "file": "json/mock_data/dashboard-data/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/fertilization/recommend/", + "status_code": 202, + "description": "Fertilization task queued", + "file": "json/mock_data/fertilization/recommend/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/fertilization/recommend/", + "status_code": 400, + "description": "Validation error", + "file": "json/mock_data/fertilization/recommend/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/fertilization/recommend/{task_id}/status/", + "status_code": 200, + "description": "Fertilization status pending", + "file": "json/mock_data/fertilization/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/fertilization/recommend/{task_id}/status/", + "status_code": 200, + "description": "Fertilization status progress", + "file": "json/mock_data/fertilization/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/fertilization/recommend/{task_id}/status/", + "status_code": 200, + "description": "Fertilization status success", + "file": "json/mock_data/fertilization/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/fertilization/recommend/{task_id}/status/", + "status_code": 200, + "description": "Fertilization status failure", + "file": "json/mock_data/fertilization/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/", + "status_code": 200, + "description": "List irrigation methods", + "file": "json/mock_data/irrigation/methods/get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/irrigation/", + "status_code": 201, + "description": "Create irrigation method", + "file": "json/mock_data/irrigation/methods/post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/irrigation/", + "status_code": 400, + "description": "Irrigation create validation error", + "file": "json/mock_data/irrigation/methods/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/irrigation/recommend/", + "status_code": 202, + "description": "Irrigation recommendation task queued", + "file": "json/mock_data/irrigation/recommend/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/irrigation/recommend/", + "status_code": 400, + "description": "Irrigation recommendation validation error", + "file": "json/mock_data/irrigation/recommend/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/recommend/{task_id}/status/", + "status_code": 200, + "description": "Irrigation recommendation status pending", + "file": "json/mock_data/irrigation/recommend/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/recommend/{task_id}/status/", + "status_code": 200, + "description": "Irrigation recommendation status progress", + "file": "json/mock_data/irrigation/recommend/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/recommend/{task_id}/status/", + "status_code": 200, + "description": "Irrigation recommendation status success", + "file": "json/mock_data/irrigation/recommend/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/recommend/{task_id}/status/", + "status_code": 200, + "description": "Irrigation recommendation status failure", + "file": "json/mock_data/irrigation/recommend/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/irrigation/{pk}/", + "status_code": 200, + "description": "Irrigation method get success", + "file": "json/mock_data/irrigation/method-detail/get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/irrigation/{pk}/", + "status_code": 404, + "description": "Irrigation method get not found", + "file": "json/mock_data/irrigation/method-detail/get_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/irrigation/{pk}/", + "status_code": 200, + "description": "Irrigation method put success", + "file": "json/mock_data/irrigation/method-detail/put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/irrigation/{pk}/", + "status_code": 400, + "description": "Irrigation method put validation error", + "file": "json/mock_data/irrigation/method-detail/put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/irrigation/{pk}/", + "status_code": 404, + "description": "Irrigation method put not found", + "file": "json/mock_data/irrigation/method-detail/put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/irrigation/{pk}/", + "status_code": 200, + "description": "Irrigation method patch success", + "file": "json/mock_data/irrigation/method-detail/patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/irrigation/{pk}/", + "status_code": 400, + "description": "Irrigation method patch validation error", + "file": "json/mock_data/irrigation/method-detail/patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/irrigation/{pk}/", + "status_code": 404, + "description": "Irrigation method patch not found", + "file": "json/mock_data/irrigation/method-detail/patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "DELETE", + "path": "/api/irrigation/{pk}/", + "status_code": 200, + "description": "Delete irrigation method", + "file": "json/mock_data/irrigation/method-detail/delete_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "DELETE", + "path": "/api/irrigation/{pk}/", + "status_code": 404, + "description": "Delete irrigation method not found", + "file": "json/mock_data/irrigation/method-detail/delete_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/", + "status_code": 200, + "description": "Soil data served from database", + "file": "json/mock_data/soil-data/get_200_database.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/", + "status_code": 202, + "description": "Soil data fetch task queued", + "file": "json/mock_data/soil-data/get_202_queued.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/", + "status_code": 400, + "description": "Soil data validation error", + "file": "json/mock_data/soil-data/get_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/soil-data/", + "status_code": 200, + "description": "Soil data POST served from database", + "file": "json/mock_data/soil-data/post_200_database.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/soil-data/", + "status_code": 202, + "description": "Soil data POST task queued", + "file": "json/mock_data/soil-data/post_202_queued.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/soil-data/", + "status_code": 400, + "description": "Soil data POST validation error", + "file": "json/mock_data/soil-data/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/tasks/{task_id}/status/", + "status_code": 200, + "description": "Soil task status pending", + "file": "json/mock_data/soil-data/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/tasks/{task_id}/status/", + "status_code": 200, + "description": "Soil task status progress", + "file": "json/mock_data/soil-data/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/tasks/{task_id}/status/", + "status_code": 200, + "description": "Soil task status success", + "file": "json/mock_data/soil-data/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/soil-data/tasks/{task_id}/status/", + "status_code": 200, + "description": "Soil task status failure", + "file": "json/mock_data/soil-data/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/plants/", + "status_code": 200, + "description": "List plants", + "file": "json/mock_data/plant/list-get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/plants/", + "status_code": 201, + "description": "Create plant", + "file": "json/mock_data/plant/create-post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/plants/", + "status_code": 400, + "description": "Plant create validation error", + "file": "json/mock_data/plant/create-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/plants/{pk}/", + "status_code": 200, + "description": "Plant detail get success", + "file": "json/mock_data/plant/detail-get_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/plants/{pk}/", + "status_code": 404, + "description": "Plant detail get not found", + "file": "json/mock_data/plant/detail-get_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/plants/{pk}/", + "status_code": 200, + "description": "Plant detail put success", + "file": "json/mock_data/plant/detail-put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/plants/{pk}/", + "status_code": 400, + "description": "Plant detail put validation error", + "file": "json/mock_data/plant/detail-put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PUT", + "path": "/api/plants/{pk}/", + "status_code": 404, + "description": "Plant detail put not found", + "file": "json/mock_data/plant/detail-put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/plants/{pk}/", + "status_code": 200, + "description": "Plant detail patch success", + "file": "json/mock_data/plant/detail-patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/plants/{pk}/", + "status_code": 400, + "description": "Plant detail patch validation error", + "file": "json/mock_data/plant/detail-patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "PATCH", + "path": "/api/plants/{pk}/", + "status_code": 404, + "description": "Plant detail patch not found", + "file": "json/mock_data/plant/detail-patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "DELETE", + "path": "/api/plants/{pk}/", + "status_code": 200, + "description": "Delete plant success", + "file": "json/mock_data/plant/detail-delete_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "DELETE", + "path": "/api/plants/{pk}/", + "status_code": 404, + "description": "Delete plant not found", + "file": "json/mock_data/plant/detail-delete_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/plants/fetch-info/", + "status_code": 200, + "description": "Fetch plant info success", + "file": "json/mock_data/plant/fetch-info-post_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/plants/fetch-info/", + "status_code": 400, + "description": "Fetch plant info missing name", + "file": "json/mock_data/plant/fetch-info-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/plants/fetch-info/", + "status_code": 503, + "description": "Fetch plant info service unavailable", + "file": "json/mock_data/plant/fetch-info-post_503.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/rag/chat/", + "status_code": 200, + "description": "RAG chat streaming response", + "file": "json/mock_data/rag/chat-post_200_stream.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/rag/chat/", + "status_code": 400, + "description": "Missing query", + "file": "json/mock_data/rag/chat-post_400_missing_query.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/rag/chat/", + "status_code": 400, + "description": "Invalid service id", + "file": "json/mock_data/rag/chat-post_400_invalid_service.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/rag/chat/", + "status_code": 400, + "description": "Missing user_id for service", + "file": "json/mock_data/rag/chat-post_400_missing_user.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "POST", + "path": "/api/rag/recommend/irrigation/", + "status_code": 202, + "description": "RAG irrigation task queued", + "file": "json/mock_data/rag/irrigation/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/rag/recommend/irrigation/", + "status_code": 400, + "description": "RAG irrigation validation error", + "file": "json/mock_data/rag/irrigation/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/irrigation/{task_id}/status/", + "status_code": 200, + "description": "RAG irrigation status pending", + "file": "json/mock_data/rag/irrigation/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/irrigation/{task_id}/status/", + "status_code": 200, + "description": "RAG irrigation status progress", + "file": "json/mock_data/rag/irrigation/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/irrigation/{task_id}/status/", + "status_code": 200, + "description": "RAG irrigation status success", + "file": "json/mock_data/rag/irrigation/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/irrigation/{task_id}/status/", + "status_code": 200, + "description": "RAG irrigation status failure", + "file": "json/mock_data/rag/irrigation/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/rag/recommend/fertilization/", + "status_code": 202, + "description": "RAG fertilization task queued", + "file": "json/mock_data/rag/fertilization/post_202.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/rag/recommend/fertilization/", + "status_code": 400, + "description": "RAG fertilization validation error", + "file": "json/mock_data/rag/fertilization/post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/fertilization/{task_id}/status/", + "status_code": 200, + "description": "RAG fertilization status pending", + "file": "json/mock_data/rag/fertilization/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/fertilization/{task_id}/status/", + "status_code": 200, + "description": "RAG fertilization status progress", + "file": "json/mock_data/rag/fertilization/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/fertilization/{task_id}/status/", + "status_code": 200, + "description": "RAG fertilization status success", + "file": "json/mock_data/rag/fertilization/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/rag/recommend/fertilization/{task_id}/status/", + "status_code": 200, + "description": "RAG fertilization status failure", + "file": "json/mock_data/rag/fertilization/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PUT", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 200, + "description": "Sensor update put success", + "file": "json/mock_data/sensor-data/update-put_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PUT", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 400, + "description": "Sensor update put validation error", + "file": "json/mock_data/sensor-data/update-put_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PUT", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 404, + "description": "Sensor update put location not found", + "file": "json/mock_data/sensor-data/update-put_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PATCH", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 200, + "description": "Sensor update patch success", + "file": "json/mock_data/sensor-data/update-patch_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PATCH", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 400, + "description": "Sensor update patch validation error", + "file": "json/mock_data/sensor-data/update-patch_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "PATCH", + "path": "/api/sensor-data/{farm_uuid}/", + "status_code": 404, + "description": "Sensor update patch location not found", + "file": "json/mock_data/sensor-data/update-patch_404.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/sensor-data/parameters/", + "status_code": 201, + "description": "Create sensor parameter", + "file": "json/mock_data/sensor-data/parameters-post_201.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/sensor-data/parameters/", + "status_code": 400, + "description": "Sensor parameter validation error", + "file": "json/mock_data/sensor-data/parameters-post_400.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "POST", + "path": "/api/tasks/", + "status_code": 200, + "description": "Task trigger success", + "file": "json/mock_data/tasks/post_200.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/tasks/{task_id}/status/", + "status_code": 200, + "description": "Task status pending", + "file": "json/mock_data/tasks/status/get_200_pending.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/tasks/{task_id}/status/", + "status_code": 200, + "description": "Task status progress", + "file": "json/mock_data/tasks/status/get_200_progress.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/tasks/{task_id}/status/", + "status_code": 200, + "description": "Task status success", + "file": "json/mock_data/tasks/status/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/tasks/{task_id}/status/", + "status_code": 200, + "description": "Task status failure", + "file": "json/mock_data/tasks/status/get_200_failure.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + }, + { + "method": "GET", + "path": "/api/pest-detection/risk-summary/", + "status_code": 200, + "description": "Pest and disease risk summary success", + "file": "json/ai/pest-detection/risk-summary/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/weather-forecast/card/", + "status_code": 200, + "description": "Farm weather card data", + "file": "json/ai/weather-forecast/card/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Spec example for a route family that exists in code, but this file remains mock/contract documentation only." + }, + { + "method": "GET", + "path": "/api/yield-harvest/summary/", + "status_code": 200, + "description": "Yield prediction card, chart and harvest prediction card", + "file": "json/ai/yield-harvest/summary/get_200_success.json", + "contract_status": "contract_only", + "integration_status": "mock_spec", + "notes": "Mock/spec example only; verify actual route registration before treating as implemented." + } +] diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_200.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_200.json new file mode 100644 index 0000000..ed52092 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_200.json @@ -0,0 +1,5 @@ +{ + "code": 200, + "msg": "روش آبیاری با موفقیت حذف شد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_404.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_404.json new file mode 100644 index 0000000..54dcfff --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/delete_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "روش آبیاری یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_200.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_200.json new file mode 100644 index 0000000..5988c67 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_404.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_404.json new file mode 100644 index 0000000..54dcfff --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/get_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "روش آبیاری یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_200.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_200.json new file mode 100644 index 0000000..5988c67 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_400.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_400.json new file mode 100644 index 0000000..2e4dd22 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field may not be blank." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_404.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_404.json new file mode 100644 index 0000000..54dcfff --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/patch_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "روش آبیاری یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_200.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_200.json new file mode 100644 index 0000000..5988c67 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_400.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_400.json new file mode 100644 index 0000000..2e4dd22 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field may not be blank." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_404.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_404.json new file mode 100644 index 0000000..54dcfff --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/method-detail/put_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "روش آبیاری یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/get_200.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/get_200.json new file mode 100644 index 0000000..a2e511e --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/get_200.json @@ -0,0 +1,20 @@ +{ + "code": 200, + "msg": "success", + "data": [ + { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } + ] +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_201.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_201.json new file mode 100644 index 0000000..2fba4f5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_201.json @@ -0,0 +1,18 @@ +{ + "code": 201, + "msg": "success", + "data": { + "id": 1, + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و راندمان بالا", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "اکثر خاک‌ها", + "climate_suitability": "گرم و خشک", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_400.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_400.json new file mode 100644 index 0000000..f72f28c --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/methods/post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_202.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_202.json new file mode 100644 index 0000000..a5f230d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_202.json @@ -0,0 +1,8 @@ +{ + "code": 202, + "msg": "تسک توصیه آبیاری در صف قرار گرفت.", + "data": { + "task_id": "irr-task-123", + "status_url": "/api/irrigation/recommend/irr-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_400.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_400.json new file mode 100644 index 0000000..5e4c3f5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "farm_uuid": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_failure.json new file mode 100644 index 0000000..5f4e1f7 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "irr-task-123", + "status": "FAILURE", + "error": "خطا در دریافت توصیه آبیاری." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_pending.json new file mode 100644 index 0000000..498b382 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "irr-task-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_progress.json new file mode 100644 index 0000000..e8837dd --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_progress.json @@ -0,0 +1,11 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "irr-task-123", + "status": "PROGRESS", + "progress": { + "message": "در حال پردازش توصیه آبیاری..." + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_success.json new file mode 100644 index 0000000..46f9c90 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/irrigation/recommend/status/get_200_success.json @@ -0,0 +1,37 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "irr-task-123", + "status": "SUCCESS", + "result": { + "plan": { + "frequencyPerWeek": 3, + "durationMinutes": 42, + "bestTimeOfDay": "صبح زود", + "moistureLevel": 68, + "warning": "در صورت بارش موثر، نوبت سوم این هفته را حذف کنید." + }, + "raw_response": "{\"plan\":{\"frequencyPerWeek\":3,\"durationMinutes\":42}}", + "water_balance": { + "daily": [ + { + "forecast_date": "2025-03-25", + "et0_mm": 4.7, + "etc_mm": 5.6, + "effective_rainfall_mm": 0.0, + "gross_irrigation_mm": 6.2, + "irrigation_timing": "06:00-08:00" + } + ], + "crop_profile": { + "kc_initial": 0.6, + "kc_mid": 1.15, + "kc_end": 0.8 + }, + "active_kc": 1.15 + }, + "status": "completed" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/pest-detection/risk-summary/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/pest-detection/risk-summary/get_200_success.json new file mode 100644 index 0000000..e403302 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/pest-detection/risk-summary/get_200_success.json @@ -0,0 +1,47 @@ +{ + "status": "success", + "service": "ai", + "path": "/pest-detection/risk-summary", + "result": { + "disease_risk": { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "۷ روز اخیر", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-bug", + "chipText": "5%", + "chipColor": "success", + "details": { + "risk_level": "low", + "risk_percentage": 5, + "detected_diseases": [], + "last_assessed_at": "2025-07-10T06:00:00Z", + "recommendation": "شرایط فعلی مناسب است. پایش هفتگی توصیه می‌شود." + } + }, + "pest_risk": { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش‌بینی هوشمند", + "stats": "15%", + "avatarColor": "warning", + "avatarIcon": "tabler-bug-off", + "chipText": "تحت نظر", + "chipColor": "warning", + "details": { + "risk_level": "moderate", + "risk_percentage": 15, + "detected_pests": [ + { + "name": "شپشک", + "confidence": 0.72, + "affected_area_percent": 8 + } + ], + "last_assessed_at": "2025-07-10T06:00:00Z", + "recommendation": "بازرسی مزرعه هر ۳ روز یک بار انجام شود. در صورت افزایش، اسپری روغن نیم توصیه می‌شود." + } + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/create-post_201.json b/Modules/Backend/external_api_adapter/json/ai/plant/create-post_201.json new file mode 100644 index 0000000..32c4614 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/create-post_201.json @@ -0,0 +1,18 @@ +{ + "code": 201, + "msg": "success", + "data": { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/create-post_400.json b/Modules/Backend/external_api_adapter/json/ai/plant/create-post_400.json new file mode 100644 index 0000000..f72f28c --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/create-post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_200.json new file mode 100644 index 0000000..b127160 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_200.json @@ -0,0 +1,5 @@ +{ + "code": 200, + "msg": "گیاه با موفقیت حذف شد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_404.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_404.json new file mode 100644 index 0000000..497519d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-delete_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "گیاه یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_200.json new file mode 100644 index 0000000..af5f8ad --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_404.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_404.json new file mode 100644 index 0000000..497519d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-get_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "گیاه یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_200.json new file mode 100644 index 0000000..af5f8ad --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_400.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_400.json new file mode 100644 index 0000000..2e4dd22 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field may not be blank." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_404.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_404.json new file mode 100644 index 0000000..497519d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-patch_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "گیاه یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_200.json new file mode 100644 index 0000000..af5f8ad --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_400.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_400.json new file mode 100644 index 0000000..2e4dd22 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "name": [ + "This field may not be blank." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_404.json b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_404.json new file mode 100644 index 0000000..497519d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/detail-put_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "گیاه یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_200.json new file mode 100644 index 0000000..af5f8ad --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_200.json @@ -0,0 +1,18 @@ +{ + "code": 200, + "msg": "success", + "data": { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_400.json b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_400.json new file mode 100644 index 0000000..e4bbdd2 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_400.json @@ -0,0 +1,5 @@ +{ + "code": 400, + "msg": "نام گیاه الزامی است.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_503.json b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_503.json new file mode 100644 index 0000000..f4911a7 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/fetch-info-post_503.json @@ -0,0 +1,5 @@ +{ + "code": 503, + "msg": "سرویس API هنوز پیاده‌سازی نشده است.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/plant/list-get_200.json b/Modules/Backend/external_api_adapter/json/ai/plant/list-get_200.json new file mode 100644 index 0000000..e6586a2 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/plant/list-get_200.json @@ -0,0 +1,20 @@ +{ + "code": 200, + "msg": "success", + "data": [ + { + "id": 1, + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ تا ۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰ تا ۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰ تا ۹۰ روز پس از کاشت", + "spacing": "۴۵ تا ۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } + ] +} diff --git a/Modules/Backend/external_api_adapter/json/ai/predict/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/predict/get_200_success.json new file mode 100644 index 0000000..0fe8cc1 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/predict/get_200_success.json @@ -0,0 +1,9 @@ +{ + "status": "success", + "service": "ai", + "path": "/predict", + "result": { + "prediction": "healthy", + "confidence": 0.97 + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_200_stream.json b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_200_stream.json new file mode 100644 index 0000000..2847fcd --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_200_stream.json @@ -0,0 +1,29 @@ +{ + "content": "Here is the recommended plan.", + "sections": [ + { + "type": "recommendation", + "title": "Irrigation Plan", + "icon": "droplet", + "frequency": "3 times per week", + "amount": "15 liters per plant", + "timing": "Early morning", + "expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough." + }, + { + "type": "list", + "title": "Important Notes", + "icon": "leaf", + "items": [ + "Avoid watering at noon", + "Check leaf stress every two days" + ] + }, + { + "type": "warning", + "title": "Heat Alert", + "icon": "warning", + "content": "Increase irrigation if temperature rises above 35°C." + } + ] +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_invalid_service.json b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_invalid_service.json new file mode 100644 index 0000000..c484816 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_invalid_service.json @@ -0,0 +1,4 @@ +{ + "code": 400, + "msg": "service_id نامعتبر است: unknown_service" +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_query.json b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_query.json new file mode 100644 index 0000000..b491272 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_query.json @@ -0,0 +1,4 @@ +{ + "code": 400, + "msg": "پارامتر query الزامی است." +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_user.json b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_user.json new file mode 100644 index 0000000..3615785 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/chat-post_400_missing_user.json @@ -0,0 +1,4 @@ +{ + "code": 400, + "msg": "برای این service_id، پارامتر user_id الزامی است." +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/chat/generate/post_202.json b/Modules/Backend/external_api_adapter/json/ai/rag/chat/generate/post_202.json new file mode 100644 index 0000000..93aee74 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/chat/generate/post_202.json @@ -0,0 +1,9 @@ +{ + "code": 202, + "msg": "تسک چت دستیار مزرعه در صف قرار گرفت.", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_202.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_202.json new file mode 100644 index 0000000..8fe7cc7 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_202.json @@ -0,0 +1,8 @@ +{ + "code": 202, + "msg": "تسک توصیه کودهی در صف قرار گرفت.", + "data": { + "task_id": "rag-fert-123", + "status_url": "/api/rag/recommend/fertilization/rag-fert-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_400.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_400.json new file mode 100644 index 0000000..b82ea08 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/post_400.json @@ -0,0 +1,5 @@ +{ + "code": 400, + "msg": "پارامتر farm_uuid الزامی است.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_failure.json new file mode 100644 index 0000000..971dd04 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-fert-123", + "status": "FAILURE", + "error": "خطا در دریافت توصیه کودهی." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_pending.json new file mode 100644 index 0000000..9c9eca6 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-fert-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_progress.json new file mode 100644 index 0000000..4cd6d57 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_progress.json @@ -0,0 +1,11 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-fert-123", + "status": "PROGRESS", + "progress": { + "message": "در حال پردازش توصیه کودهی..." + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_success.json new file mode 100644 index 0000000..cea132a --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/fertilization/status/get_200_success.json @@ -0,0 +1,19 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-fert-123", + "status": "SUCCESS", + "result": { + "plan": { + "npkRatio": "20-20-20", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "کودآبیاری در دو نوبت", + "applicationInterval": "هر ۱۰ روز", + "reasoning": "نیتروژن و پتاسیم خاک در محدوده متوسط است و گیاه در فاز رویشی نیاز تغذیه‌ای بالاتری دارد." + }, + "raw_response": "{\"plan\":{\"npkRatio\":\"20-20-20\"}}", + "status": "completed" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_202.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_202.json new file mode 100644 index 0000000..bcc6db9 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_202.json @@ -0,0 +1,8 @@ +{ + "code": 202, + "msg": "تسک توصیه آبیاری در صف قرار گرفت.", + "data": { + "task_id": "rag-irr-123", + "status_url": "/api/rag/recommend/irrigation/rag-irr-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_400.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_400.json new file mode 100644 index 0000000..b82ea08 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/post_400.json @@ -0,0 +1,5 @@ +{ + "code": 400, + "msg": "پارامتر farm_uuid الزامی است.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_failure.json new file mode 100644 index 0000000..bffa098 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-irr-123", + "status": "FAILURE", + "error": "خطا در دریافت توصیه آبیاری." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_pending.json new file mode 100644 index 0000000..1074e86 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-irr-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_progress.json new file mode 100644 index 0000000..3b88990 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_progress.json @@ -0,0 +1,11 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-irr-123", + "status": "PROGRESS", + "progress": { + "message": "در حال پردازش توصیه آبیاری..." + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_success.json new file mode 100644 index 0000000..5679648 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/rag/irrigation/status/get_200_success.json @@ -0,0 +1,37 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "rag-irr-123", + "status": "SUCCESS", + "result": { + "plan": { + "frequencyPerWeek": 3, + "durationMinutes": 42, + "bestTimeOfDay": "صبح زود", + "moistureLevel": 68, + "warning": "در صورت بارش موثر، نوبت سوم این هفته را حذف کنید." + }, + "raw_response": "{\"plan\":{\"frequencyPerWeek\":3,\"durationMinutes\":42}}", + "water_balance": { + "daily": [ + { + "forecast_date": "2025-03-25", + "et0_mm": 4.7, + "etc_mm": 5.6, + "effective_rainfall_mm": 0.0, + "gross_irrigation_mm": 6.2, + "irrigation_timing": "06:00-08:00" + } + ], + "crop_profile": { + "kc_initial": 0.6, + "kc_mid": 1.15, + "kc_end": 0.8 + }, + "active_kc": 1.15 + }, + "status": "completed" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_201.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_201.json new file mode 100644 index 0000000..9de5e9d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_201.json @@ -0,0 +1,12 @@ +{ + "code": 201, + "msg": "success", + "data": { + "id": 3, + "code": "soil_moisture", + "name_fa": "رطوبت خاک", + "unit": "%", + "created_at": "2025-03-24T10:00:00Z", + "action": "added" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_400.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_400.json new file mode 100644 index 0000000..98b89aa --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/parameters-post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "code": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_200.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_200.json new file mode 100644 index 0000000..31fc9cd --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_200.json @@ -0,0 +1,20 @@ +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "location_id": 12, + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + "plant_ids": [ + 1 + ], + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_400.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_400.json new file mode 100644 index 0000000..cf343c5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "location_id": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_404.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_404.json new file mode 100644 index 0000000..107ea33 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-patch_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "location_id یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_200.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_200.json new file mode 100644 index 0000000..31fc9cd --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_200.json @@ -0,0 +1,20 @@ +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "location_id": 12, + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + "plant_ids": [ + 1 + ], + "created_at": "2025-03-20T10:00:00Z", + "updated_at": "2025-03-24T10:00:00Z" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_400.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_400.json new file mode 100644 index 0000000..cf343c5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "location_id": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_404.json b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_404.json new file mode 100644 index 0000000..107ea33 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/sensor-data/update-put_404.json @@ -0,0 +1,5 @@ +{ + "code": 404, + "msg": "location_id یافت نشد.", + "data": null +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/get_200_database.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_200_database.json new file mode 100644 index 0000000..87becb1 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_200_database.json @@ -0,0 +1,63 @@ +{ + "code": 200, + "msg": "success", + "data": { + "source": "database", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "depths": [ + { + "depth_label": "0-5cm", + "bdod": 1.31, + "cec": 18.4, + "cfvo": 2.0, + "clay": 24.0, + "nitrogen": 0.18, + "ocd": 32.0, + "ocs": 4.1, + "phh2o": 7.2, + "sand": 34.0, + "silt": 42.0, + "soc": 1.6, + "wv0010": 0.31, + "wv0033": 0.22, + "wv1500": 0.11 + }, + { + "depth_label": "5-15cm", + "bdod": 1.35, + "cec": 17.2, + "cfvo": 2.3, + "clay": 26.0, + "nitrogen": 0.16, + "ocd": 28.0, + "ocs": 3.7, + "phh2o": 7.1, + "sand": 36.0, + "silt": 38.0, + "soc": 1.4, + "wv0010": 0.29, + "wv0033": 0.2, + "wv1500": 0.1 + }, + { + "depth_label": "15-30cm", + "bdod": 1.39, + "cec": 15.8, + "cfvo": 2.8, + "clay": 28.0, + "nitrogen": 0.13, + "ocd": 22.0, + "ocs": 3.2, + "phh2o": 7.0, + "sand": 38.0, + "silt": 34.0, + "soc": 1.1, + "wv0010": 0.26, + "wv0033": 0.18, + "wv1500": 0.09 + } + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/get_202_queued.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_202_queued.json new file mode 100644 index 0000000..5a06843 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_202_queued.json @@ -0,0 +1,11 @@ +{ + "code": 202, + "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", + "data": { + "source": "task", + "task_id": "soil-task-123", + "lon": 51.389, + "lat": 35.6892, + "status_url": "/api/soil-data/tasks/soil-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/get_400.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_400.json new file mode 100644 index 0000000..bfc19ab --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/get_400.json @@ -0,0 +1,12 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "lat": [ + "This field is required." + ], + "lon": [ + "This field is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/post_200_database.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_200_database.json new file mode 100644 index 0000000..87becb1 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_200_database.json @@ -0,0 +1,63 @@ +{ + "code": 200, + "msg": "success", + "data": { + "source": "database", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "depths": [ + { + "depth_label": "0-5cm", + "bdod": 1.31, + "cec": 18.4, + "cfvo": 2.0, + "clay": 24.0, + "nitrogen": 0.18, + "ocd": 32.0, + "ocs": 4.1, + "phh2o": 7.2, + "sand": 34.0, + "silt": 42.0, + "soc": 1.6, + "wv0010": 0.31, + "wv0033": 0.22, + "wv1500": 0.11 + }, + { + "depth_label": "5-15cm", + "bdod": 1.35, + "cec": 17.2, + "cfvo": 2.3, + "clay": 26.0, + "nitrogen": 0.16, + "ocd": 28.0, + "ocs": 3.7, + "phh2o": 7.1, + "sand": 36.0, + "silt": 38.0, + "soc": 1.4, + "wv0010": 0.29, + "wv0033": 0.2, + "wv1500": 0.1 + }, + { + "depth_label": "15-30cm", + "bdod": 1.39, + "cec": 15.8, + "cfvo": 2.8, + "clay": 28.0, + "nitrogen": 0.13, + "ocd": 22.0, + "ocs": 3.2, + "phh2o": 7.0, + "sand": 38.0, + "silt": 34.0, + "soc": 1.1, + "wv0010": 0.26, + "wv0033": 0.18, + "wv1500": 0.09 + } + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/post_202_queued.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_202_queued.json new file mode 100644 index 0000000..5a06843 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_202_queued.json @@ -0,0 +1,11 @@ +{ + "code": 202, + "msg": "تسک در صف. وضعیت را با task_id بررسی کنید.", + "data": { + "source": "task", + "task_id": "soil-task-123", + "lon": 51.389, + "lat": 35.6892, + "status_url": "/api/soil-data/tasks/soil-task-123/status/" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/post_400.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_400.json new file mode 100644 index 0000000..4d32daf --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/post_400.json @@ -0,0 +1,9 @@ +{ + "code": 400, + "msg": "داده نامعتبر.", + "data": { + "lat": [ + "A valid number is required." + ] + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_failure.json new file mode 100644 index 0000000..a0c0697 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "soil-task-123", + "status": "FAILURE", + "error": "خطا در واکشی داده خاک." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_pending.json new file mode 100644 index 0000000..b4965ce --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "soil-task-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_progress.json new file mode 100644 index 0000000..29ecfe5 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_progress.json @@ -0,0 +1,12 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "soil-task-123", + "status": "PROGRESS", + "progress": { + "step": "fetch", + "percent": 60 + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_success.json new file mode 100644 index 0000000..04d0827 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/soil-data/status/get_200_success.json @@ -0,0 +1,67 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "soil-task-123", + "status": "SUCCESS", + "result": { + "source": "database", + "id": 12, + "lon": "51.389000", + "lat": "35.689200", + "depths": [ + { + "depth_label": "0-5cm", + "bdod": 1.31, + "cec": 18.4, + "cfvo": 2.0, + "clay": 24.0, + "nitrogen": 0.18, + "ocd": 32.0, + "ocs": 4.1, + "phh2o": 7.2, + "sand": 34.0, + "silt": 42.0, + "soc": 1.6, + "wv0010": 0.31, + "wv0033": 0.22, + "wv1500": 0.11 + }, + { + "depth_label": "5-15cm", + "bdod": 1.35, + "cec": 17.2, + "cfvo": 2.3, + "clay": 26.0, + "nitrogen": 0.16, + "ocd": 28.0, + "ocs": 3.7, + "phh2o": 7.1, + "sand": 36.0, + "silt": 38.0, + "soc": 1.4, + "wv0010": 0.29, + "wv0033": 0.2, + "wv1500": 0.1 + }, + { + "depth_label": "15-30cm", + "bdod": 1.39, + "cec": 15.8, + "cfvo": 2.8, + "clay": 28.0, + "nitrogen": 0.13, + "ocd": 22.0, + "ocs": 3.2, + "phh2o": 7.0, + "sand": 38.0, + "silt": 34.0, + "soc": 1.1, + "wv0010": 0.26, + "wv0033": 0.18, + "wv1500": 0.09 + } + ] + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/tasks/post_200.json b/Modules/Backend/external_api_adapter/json/ai/tasks/post_200.json new file mode 100644 index 0000000..8fc9fe2 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/tasks/post_200.json @@ -0,0 +1,7 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "sample-task-123" + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_failure.json b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_failure.json new file mode 100644 index 0000000..c524d68 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_failure.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "FAILURE", + "error": "Sample task failed." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_pending.json b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_pending.json new file mode 100644 index 0000000..5470c7d --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_pending.json @@ -0,0 +1,9 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "message": "تسک در صف یا یافت نشد." + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_progress.json b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_progress.json new file mode 100644 index 0000000..0a3107e --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_progress.json @@ -0,0 +1,13 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PROGRESS", + "progress": { + "current": 1, + "total": 3, + "message": "در حال پردازش..." + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_success.json new file mode 100644 index 0000000..c5b78bd --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/tasks/status/get_200_success.json @@ -0,0 +1,11 @@ +{ + "code": 200, + "msg": "success", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "SUCCESS", + "result": { + "$ref": "rag/chat-post_200_stream.json" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/weather-forecast/card/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/weather-forecast/card/get_200_success.json new file mode 100644 index 0000000..4049557 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/weather-forecast/card/get_200_success.json @@ -0,0 +1,17 @@ +{ + "status": "success", + "service": "ai", + "path": "/weather-forecast/card", + "result": { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"], + "series": [[18, 22, 26, 28, 25, 20, 18]] + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/ai/yield-harvest/summary/get_200_success.json b/Modules/Backend/external_api_adapter/json/ai/yield-harvest/summary/get_200_success.json new file mode 100644 index 0000000..bc1d13b --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/ai/yield-harvest/summary/get_200_success.json @@ -0,0 +1,51 @@ +{ + "status": "success", + "service": "ai", + "path": "/yield-harvest/summary", + "result": { + "yield_prediction_card": { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": "42 تن", + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "+8%", + "chipColor": "success" + }, + "yield_prediction_chart": { + "categories": [ + "ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", + "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر" + ], + "series": [ + {"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]}, + {"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]} + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": "42 تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar" + }, + { + "title": "تاریخ برداشت", + "subtitle": "حدود ۱۵ اکتبر", + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar" + } + ] + }, + "harvest_prediction_card": { + "date": "2025-10-15", + "dateFormatted": "۱۵ اکتبر ۲۰۲۵", + "daysUntil": 58, + "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", + "optimalWindowStart": "2025-10-12", + "optimalWindowEnd": "2025-10-18" + } + } +} diff --git a/Modules/Backend/external_api_adapter/json/sensor_hub/.gitkeep b/Modules/Backend/external_api_adapter/json/sensor_hub/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/external_api_adapter/json/sensor_hub/.gitkeep @@ -0,0 +1 @@ + diff --git a/Modules/Backend/external_api_adapter/mock_loader.py b/Modules/Backend/external_api_adapter/mock_loader.py new file mode 100644 index 0000000..17a10c7 --- /dev/null +++ b/Modules/Backend/external_api_adapter/mock_loader.py @@ -0,0 +1,163 @@ +import json +from dataclasses import dataclass +from pathlib import Path + +from .exceptions import MockDirectoryNotFound, MockFileNotFound + + +@dataclass +class LoadedMockResponse: + data: object + status_code: int + file_path: str + + +class MockLoader: + def __init__(self, base_path=None): + self.base_path = Path(base_path or Path(__file__).resolve().parent / "json") + + def load(self, service_name, path, method): + mock_files = self._find_mock_files(service_name=service_name, path=path, method=method) + if not mock_files: + raise MockFileNotFound( + f"No mock file found for service='{service_name}' path='{path}' method='{method}'." + ) + + selected_file = sorted(mock_files, key=self._mock_file_priority)[0] + with selected_file.open("r", encoding="utf-8") as file: + service_root = self.base_path / service_name + return LoadedMockResponse( + data=self._resolve_references( + json.load(file), + current_file=selected_file, + service_root=service_root, + ), + status_code=self._extract_status_code(selected_file), + file_path=str(selected_file), + ) + + def _find_mock_files(self, service_name, path, method): + service_root = self.base_path / service_name + directory_path = service_root / self._build_directory_path(path) + pattern = f"{method.lower()}_*.json" + + if directory_path.exists() and directory_path.is_dir(): + return list(directory_path.glob(pattern)) + + leaf_name = self._extract_leaf_name(path) + parent_directory = directory_path.parent + if parent_directory.exists() and parent_directory.is_dir(): + flat_pattern = f"{leaf_name}-{method.lower()}_*.json" + flat_files = list(parent_directory.glob(flat_pattern)) + if flat_files: + return flat_files + + normalized_parts = [part for part in str(path).strip().strip("/").split("/") if part] + for index in range(len(normalized_parts) - 1, -1, -1): + candidate_parts = normalized_parts[:index] + normalized_parts[index + 1 :] + if not candidate_parts: + continue + + candidate_directory = service_root / Path(*candidate_parts) + if candidate_directory.exists() and candidate_directory.is_dir(): + candidate_files = list(candidate_directory.glob(pattern)) + if candidate_files: + return candidate_files + + raise MockDirectoryNotFound( + f"Mock directory not found for service='{service_name}' path='{path}': {directory_path}" + ) + + @staticmethod + def _build_directory_path(path): + normalized = str(path).strip().strip("/") + if not normalized: + return Path(".") + return Path(*normalized.split("/")) + + @staticmethod + def _extract_status_code(file_path): + parts = file_path.stem.split("_") + if len(parts) < 2: + return 200 + try: + return int(parts[1]) + except ValueError: + return 200 + + @staticmethod + def _extract_leaf_name(path): + normalized = str(path).strip().strip("/") + if not normalized: + return "" + return normalized.split("/")[-1] + + @staticmethod + def _mock_file_priority(file_path): + stem = file_path.stem.lower() + status_code = MockLoader._extract_status_code(file_path) + keyword_rank = 2 + if "success" in stem: + keyword_rank = 0 + elif "stream" in stem: + keyword_rank = 0 + elif "pending" in stem or "progress" in stem: + keyword_rank = 1 + return (status_code >= 400, keyword_rank, stem) + + def _resolve_references(self, value, current_file, service_root, seen=None): + current_file = current_file.resolve() + service_root = service_root.resolve() + seen = seen or {current_file} + + if isinstance(value, list): + return [ + self._resolve_references(item, current_file=current_file, service_root=service_root, seen=seen) + for item in value + ] + + if not isinstance(value, dict): + return value + + ref_path = value.get("$ref") + if isinstance(ref_path, str) and len(value) == 1: + referenced_file = self._resolve_reference_path( + ref_path=ref_path, + current_file=current_file, + service_root=service_root, + ) + + if referenced_file in seen: + raise MockFileNotFound(f"Circular mock reference detected for '{referenced_file}'.") + + with referenced_file.open("r", encoding="utf-8") as file: + return self._resolve_references( + json.load(file), + current_file=referenced_file, + service_root=service_root, + seen=seen | {referenced_file}, + ) + + return { + key: self._resolve_references(item, current_file=current_file, service_root=service_root, seen=seen) + for key, item in value.items() + } + + @staticmethod + def _resolve_reference_path(ref_path, current_file, service_root): + candidates = [ + current_file.parent / ref_path, + service_root / ref_path, + ] + + service_root_resolved = service_root.resolve() + for candidate in candidates: + candidate_resolved = candidate.resolve() + try: + candidate_resolved.relative_to(service_root_resolved) + except ValueError: + continue + if candidate_resolved.is_file(): + return candidate_resolved + + raise MockFileNotFound(f"Referenced mock file '{ref_path}' was not found.") diff --git a/Modules/Backend/external_api_adapter/services.py b/Modules/Backend/external_api_adapter/services.py new file mode 100644 index 0000000..835fb6e --- /dev/null +++ b/Modules/Backend/external_api_adapter/services.py @@ -0,0 +1,14 @@ +from django.conf import settings + +from .exceptions import ServiceNotFound + + +class ServiceRegistry: + def __init__(self): + self._services = getattr(settings, "EXTERNAL_SERVICES", {}) + + def get(self, service_name): + service = self._services.get(service_name) + if service is None: + raise ServiceNotFound(f"Unknown external service: '{service_name}'") + return service diff --git a/Modules/Backend/external_api_adapter/tests.py b/Modules/Backend/external_api_adapter/tests.py new file mode 100644 index 0000000..f51a4be --- /dev/null +++ b/Modules/Backend/external_api_adapter/tests.py @@ -0,0 +1,60 @@ +import uuid +from unittest.mock import patch + +from django.test import SimpleTestCase, override_settings + +from .adapter import ExternalAPIAdapter + + +class ExternalAPIAdapterTests(SimpleTestCase): + @override_settings(EXTERNAL_API_TIMEOUT=30) + @patch("external_api_adapter.adapter.requests.request") + def test_request_serializes_uuid_payload_for_json_requests(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = {"ok": True} + farm_uuid = uuid.uuid4() + + adapter = ExternalAPIAdapter( + service_registry=type( + "Registry", + (), + {"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}}, + )() + ) + + adapter.request( + "ai", + "/api/farm-alerts/tracker/", + method="POST", + payload={"farm_uuid": farm_uuid}, + ) + + mock_request.assert_called_once() + request_kwargs = mock_request.call_args.kwargs + self.assertEqual(request_kwargs["json"], {"farm_uuid": str(farm_uuid)}) + + @override_settings(EXTERNAL_API_TIMEOUT=30) + @patch("external_api_adapter.adapter.requests.request") + def test_request_serializes_uuid_payload_for_multipart_requests(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.json.return_value = {"ok": True} + farm_uuid = uuid.uuid4() + + adapter = ExternalAPIAdapter( + service_registry=type( + "Registry", + (), + {"get": lambda self, name: {"base_url": "https://example.com", "api_key": "token"}}, + )() + ) + + adapter.request( + "ai", + "/api/upload/", + method="POST", + payload={"farm_uuid": farm_uuid, "__files__": {"image": ("leaf.jpg", b"data", "image/jpeg")}}, + ) + + mock_request.assert_called_once() + request_kwargs = mock_request.call_args.kwargs + self.assertEqual(request_kwargs["data"], {"farm_uuid": str(farm_uuid)}) diff --git a/Modules/Backend/farm_ai_assistant/__init__.py b/Modules/Backend/farm_ai_assistant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farm_ai_assistant/chat_api_changes_last_6_commits.md b/Modules/Backend/farm_ai_assistant/chat_api_changes_last_6_commits.md new file mode 100644 index 0000000..67e9ce0 --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/chat_api_changes_last_6_commits.md @@ -0,0 +1,105 @@ +# تغییرات API چت در `farm_ai_assistant/urls.py` + +این فایل تغییرات مربوط به API چت را در `farm_ai_assistant/urls.py` نسبت به **۶ کامیت قبل** (`HEAD~6`) توضیح می‌دهد. + +## بازه مقایسه +- مبدا مقایسه: `HEAD~6` +- مقصد مقایسه: `HEAD` + +کامیت مبنا: +- `2a77f90` - `Update Docker Compose ports to 8081 and add new apps and URL routes for crop zoning, plant simulator, pest detection, irrigation recommendation, fertilization recommendation, and farm AI assistant.` + +کامیت‌های داخل این بازه: +- `2846db1` - `UPDATE` +- `bf24404` - `UPDATE` +- `cef1b53` - `UPDATE` +- `24cb87d` - `UPDATE` +- `2cd96ce` - `UPDATE` + +## خلاصه تغییر اصلی +در این بازه، ساختار API چت از حالت **task-based / async polling** به حالت **direct chat endpoint** تغییر کرده است. + +به زبان ساده: +- قبلاً endpoint اصلی `chat/` غیرفعال بود. +- قبلاً برای ارسال درخواست چت، یک task ساخته می‌شد. +- سپس وضعیت آن task با یک endpoint جداگانه بررسی می‌شد. +- الان این مدل حذف شده و به‌جای آن endpoint مستقیم `chat/` فعال شده است. + +## تغییرات دقیق در مسیرها + +### 1) فعال شدن endpoint مستقیم چت +مسیر زیر فعال شده است: + +- `POST/GET farm_ai_assistant/chat/` +- view متناظر: `ChatView` +- name: `farm-ai-assistant-chat` + +وضعیت قبلی: +- این خط در فایل وجود داشت اما کامنت شده بود: + - `# path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),` + +وضعیت جدید: +- این endpoint از حالت comment خارج شده و فعال شده است: + - `path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),` + +### 2) حذف endpoint ساخت task برای چت +این مسیر حذف شده است: + +- `chat/task/` +- view: `ChatTaskCreateView` +- name: `farm-ai-assistant-chat-task-create` + +هدف قبلی این endpoint: +- ایجاد یک task برای پردازش درخواست چت + +### 3) حذف endpoint بررسی وضعیت task +این مسیر هم حذف شده است: + +- `chat/task//status/` +- view: `ChatTaskStatusView` +- name: `farm-ai-assistant-chat-task-status` + +هدف قبلی این endpoint: +- بررسی وضعیت پردازش task چت با استفاده از `task_id` + +### 4) حذف import های مربوط به task-based flow +این importها از فایل حذف شده‌اند: + +- `ChatTaskCreateView` +- `ChatTaskStatusView` + +این یعنی دیگر routeای در `urls.py` برای این دو view تعریف نشده است. + +## چیزهایی که تغییری نکرده‌اند +این endpointها در بازه مقایسه بدون تغییر باقی مانده‌اند: + +- `context/` -> `ContextView` +- `chats/` -> `ChatListCreateView` +- `chats//` -> `ChatDetailView` +- `chats//messages/` -> `ChatMessagesView` + +## نتیجه فنی تغییر +این تغییر نشان می‌دهد طراحی API چت از این الگو: + +1. ساخت task +2. دریافت `task_id` +3. polling برای status + +به این الگو تغییر کرده است: + +1. ارسال مستقیم درخواست به `chat/` +2. دریافت مستقیم پاسخ از `ChatView` + +## اثر احتمالی روی فرانت یا کلاینت‌ها +اگر فرانت یا کلاینت قبلاً با flow تسک‌محور کار می‌کرده، باید این تغییرات را اعمال کند: + +- دیگر نباید به `chat/task/` درخواست بزند. +- دیگر نباید `task_id` دریافت و status را polling کند. +- باید مستقیماً از `chat/` برای عملیات چت استفاده کند. + +## جمع‌بندی +مهم‌ترین تغییر در ۶ کامیت اخیر برای `farm_ai_assistant/urls.py` این است که: + +- endpoint مستقیم `chat/` فعال شده +- endpointهای task-based حذف شده‌اند +- معماری API چت از حالت asynchronous polling به حالت direct request/response تغییر کرده است diff --git a/Modules/Backend/farm_ai_assistant/defaults.py b/Modules/Backend/farm_ai_assistant/defaults.py new file mode 100644 index 0000000..ae79129 --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/defaults.py @@ -0,0 +1,9 @@ +CONTEXT_RESPONSE_TEMPLATE = { + "soilType": None, + "waterEC": None, + "selectedCrop": None, + "growthStage": None, + "lastIrrigationStatus": None, + "status": "success", + "source": "default_template", +} diff --git a/Modules/Backend/farm_ai_assistant/migrations/0001_initial.py b/Modules/Backend/farm_ai_assistant/migrations/0001_initial.py new file mode 100644 index 0000000..2a5c7ce --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/migrations/0001_initial.py @@ -0,0 +1,49 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Conversation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("farm_context", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("owner", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="farm_ai_conversations", to=settings.AUTH_USER_MODEL)), + ], + options={ + "db_table": "farm_ai_conversations", + "ordering": ["-updated_at", "-created_at"], + }, + ), + migrations.CreateModel( + name="Message", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("role", models.CharField(choices=[("user", "User"), ("assistant", "Assistant")], max_length=32)), + ("content", models.TextField(blank=True, default="")), + ("images", models.JSONField(blank=True, default=list)), + ("raw_response", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("conversation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="messages", to="farm_ai_assistant.conversation")), + ], + options={ + "db_table": "farm_ai_messages", + "ordering": ["created_at", "id"], + }, + ), + ] diff --git a/Modules/Backend/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py b/Modules/Backend/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py new file mode 100644 index 0000000..567ce8e --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/migrations/0002_conversation_farm_message_farm.py @@ -0,0 +1,34 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ("farm_ai_assistant", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="conversation", + name="farm", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_conversations", + to="farm_hub.farmhub", + ), + ), + migrations.AddField( + model_name="message", + name="farm", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="ai_messages", + to="farm_hub.farmhub", + ), + ), + ] diff --git a/Modules/Backend/farm_ai_assistant/migrations/__init__.py b/Modules/Backend/farm_ai_assistant/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farm_ai_assistant/mock_data.py b/Modules/Backend/farm_ai_assistant/mock_data.py new file mode 100644 index 0000000..dc41e2d --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/mock_data.py @@ -0,0 +1,87 @@ +""" +Static mock data for Farm AI Assistant API. +""" + +CHAT_RESPONSE_DATA = { + "message_id": "msg-001", + "conversation_id": "conv-123", + "content": "Here is the recommended plan.", + "sections": [ + { + "type": "recommendation", + "title": "Irrigation Plan", + "icon": "droplet", + "frequency": "3 times per week", + "amount": "15 liters per plant", + "timing": "Early morning", + "expandableExplanation": "Loamy soil holds moisture well, so moderate frequency is enough.", + }, + { + "type": "list", + "title": "Important Notes", + "icon": "leaf", + "items": [ + "Avoid watering at noon", + "Check leaf stress every two days", + ], + }, + { + "type": "warning", + "title": "Heat Alert", + "icon": "warning", + "content": "Increase irrigation if temperature rises above 35°C.", + }, + ], +} + +CHAT_LIST_RESPONSE_DATA = [ + { + "id": "conv-123", + "message_count": 4, + }, + { + "id": "conv-456", + "message_count": 2, + }, +] + +CHAT_MESSAGES_RESPONSE_DATA = { + "conversation_id": "conv-123", + "messages": [ + { + "message_id": "msg-user-001", + "conversation_id": "conv-123", + "role": "user", + "content": "What is the best irrigation plan for tomato?", + "sections": [], + "images": [], + "created_at": "2025-01-01T08:00:00Z", + }, + { + "message_id": "msg-001", + "conversation_id": "conv-123", + "role": "assistant", + "content": "Here is the recommended plan.", + "sections": CHAT_RESPONSE_DATA["sections"], + "images": [], + "created_at": "2025-01-01T08:00:05Z", + }, + ], +} + +CHAT_CREATE_RESPONSE_DATA = { + "id": "conv-789", + "message_count": 0, +} + +CHAT_DELETE_RESPONSE_DATA = { + "conversation_id": "conv-123", +} + +CONTEXT_RESPONSE_DATA = { + "soilType": "Loamy", + "waterEC": "1.2 dS/m", + "selectedCrop": "Tomato", + "growthStage": "Flowering", + "lastIrrigationStatus": "2 days ago", +} diff --git a/Modules/Backend/farm_ai_assistant/models.py b/Modules/Backend/farm_ai_assistant/models.py new file mode 100644 index 0000000..9283c67 --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/models.py @@ -0,0 +1,68 @@ +import uuid + +from django.conf import settings +from django.db import models + +from farm_hub.models import FarmHub + + +class Conversation(models.Model): + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="farm_ai_conversations", + ) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="ai_conversations", + null=True, + blank=True, + ) + title = models.CharField(max_length=255, blank=True, default="") + farm_context = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_ai_conversations" + ordering = ["-updated_at", "-created_at"] + + def __str__(self): + return self.title or f"Conversation {self.uuid}" + + +class Message(models.Model): + ROLE_USER = "user" + ROLE_ASSISTANT = "assistant" + ROLE_CHOICES = ( + (ROLE_USER, "User"), + (ROLE_ASSISTANT, "Assistant"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + conversation = models.ForeignKey( + Conversation, + on_delete=models.CASCADE, + related_name="messages", + ) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="ai_messages", + null=True, + blank=True, + ) + role = models.CharField(max_length=32, choices=ROLE_CHOICES) + content = models.TextField(blank=True, default="") + images = models.JSONField(default=list, blank=True) + raw_response = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_ai_messages" + ordering = ["created_at", "id"] + + def __str__(self): + return f"{self.role}: {self.uuid}" diff --git a/Modules/Backend/farm_ai_assistant/postman/farm_ai_assistant.json b/Modules/Backend/farm_ai_assistant/postman/farm_ai_assistant.json new file mode 100644 index 0000000..7578707 --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/postman/farm_ai_assistant.json @@ -0,0 +1,120 @@ +{ + "info": { + "name": "Farm AI Assistant", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Farm AI Assistant API. Context, chat send, chat list/create, message history, and chat delete." + }, + "item": [ + { + "name": "Get farm context (GET)", + "request": { + "method": "GET", + "header": [{"key": "Content-Type", "value": "application/json"}], + "url": "{{baseUrl}}/api/farm-ai-assistant/context/", + "description": "Returns static farm context for the context bar." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"soilType\": \"Loamy\",\n \"waterEC\": \"1.2 dS/m\",\n \"selectedCrop\": \"Tomato\",\n \"growthStage\": \"Flowering\",\n \"lastIrrigationStatus\": \"2 days ago\"\n }\n}" + } + ] + }, + { + "name": "List chats (GET)", + "request": { + "method": "GET", + "header": [{"key": "Content-Type", "value": "application/json"}], + "url": "{{baseUrl}}/api/farm-ai-assistant/chats/", + "description": "Returns only chat id and message count for the current user." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": [\n {\n \"id\": \"conv-123\",\n \"message_count\": 4\n },\n {\n \"id\": \"conv-456\",\n \"message_count\": 2\n }\n ]\n}" + } + ] + }, + { + "name": "Create chat (POST)", + "request": { + "method": "POST", + "header": [{"key": "Content-Type", "value": "application/json"}], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"New chat\"\n}" + }, + "url": "{{baseUrl}}/api/farm-ai-assistant/chats/", + "description": "Creates a new empty chat for the current user." + }, + "response": [ + { + "name": "Created", + "status": "Created", + "code": 201, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"id\": \"conv-789\",\n \"message_count\": 0\n }\n}" + } + ] + }, + { + "name": "Get chat messages (GET)", + "request": { + "method": "GET", + "header": [{"key": "Content-Type", "value": "application/json"}], + "url": "{{baseUrl}}/api/farm-ai-assistant/chats/conv-123/messages/", + "description": "Returns all user and assistant messages for one chat." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"conversation_id\": \"conv-123\",\n \"messages\": [\n {\n \"message_id\": \"msg-user-001\",\n \"conversation_id\": \"conv-123\",\n \"role\": \"user\",\n \"content\": \"What is the best irrigation plan for tomato?\",\n \"sections\": [],\n \"images\": [],\n \"created_at\": \"2025-01-01T08:00:00Z\"\n },\n {\n \"message_id\": \"msg-001\",\n \"conversation_id\": \"conv-123\",\n \"role\": \"assistant\",\n \"content\": \"Here is the recommended plan.\",\n \"sections\": [\n {\n \"type\": \"recommendation\",\n \"title\": \"Irrigation Plan\",\n \"icon\": \"droplet\",\n \"frequency\": \"3 times per week\",\n \"amount\": \"15 liters per plant\",\n \"timing\": \"Early morning\",\n \"expandableExplanation\": \"Loamy soil holds moisture well, so moderate frequency is enough.\"\n }\n ],\n \"images\": [],\n \"created_at\": \"2025-01-01T08:00:05Z\"\n }\n ]\n }\n}" + } + ] + }, + { + "name": "Send chat message (POST)", + "request": { + "method": "POST", + "header": [{"key": "Content-Type", "value": "application/json"}], + "body": { + "mode": "raw", + "raw": "{\n \"conversation_id\": \"conv-123\",\n \"content\": \"What is the best irrigation plan for tomato?\",\n \"farm_context\": {\n \"soilType\": \"Loamy\",\n \"waterEC\": \"1.2 dS/m\",\n \"selectedCrop\": \"Tomato\",\n \"growthStage\": \"Flowering\",\n \"lastIrrigationStatus\": \"2 days ago\"\n }\n}" + }, + "url": "{{baseUrl}}/api/farm-ai-assistant/chat/", + "description": "Sends a user message and returns a structured assistant reply." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"message_id\": \"msg-001\",\n \"conversation_id\": \"conv-123\",\n \"content\": \"Here is the recommended plan.\",\n \"sections\": [\n {\n \"type\": \"recommendation\",\n \"title\": \"Irrigation Plan\",\n \"icon\": \"droplet\",\n \"frequency\": \"3 times per week\",\n \"amount\": \"15 liters per plant\",\n \"timing\": \"Early morning\",\n \"expandableExplanation\": \"Loamy soil holds moisture well, so moderate frequency is enough.\"\n },\n {\n \"type\": \"list\",\n \"title\": \"Important Notes\",\n \"icon\": \"leaf\",\n \"items\": [\n \"Avoid watering at noon\",\n \"Check leaf stress every two days\"\n ]\n },\n {\n \"type\": \"warning\",\n \"title\": \"Heat Alert\",\n \"icon\": \"warning\",\n \"content\": \"Increase irrigation if temperature rises above 35°C.\"\n }\n ]\n }\n}" + } + ] + }, + { + "name": "Delete chat (DELETE)", + "request": { + "method": "DELETE", + "header": [{"key": "Content-Type", "value": "application/json"}], + "url": "{{baseUrl}}/api/farm-ai-assistant/chats/conv-123/", + "description": "Deletes one chat and all messages inside it." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"status\": \"success\",\n \"data\": {\n \"conversation_id\": \"conv-123\"\n }\n}" + } + ] + } + ], + "variable": [{"key": "baseUrl", "value": "http://localhost:8000"}] +} diff --git a/Modules/Backend/farm_ai_assistant/serializers.py b/Modules/Backend/farm_ai_assistant/serializers.py new file mode 100644 index 0000000..f6f2d5f --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/serializers.py @@ -0,0 +1,97 @@ +from rest_framework import serializers + +from .models import Message + + +class ChatSectionSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"]) + title = serializers.CharField(required=False, allow_blank=True) + content = serializers.CharField(required=False, allow_blank=True) + items = serializers.ListField(child=serializers.CharField(), required=False) + icon = serializers.CharField(required=False, allow_blank=True) + frequency = serializers.CharField(required=False, allow_blank=True) + amount = serializers.CharField(required=False, allow_blank=True) + timing = serializers.CharField(required=False, allow_blank=True) + primaryAction = serializers.CharField(required=False, allow_blank=True) + validityPeriod = serializers.CharField(required=False, allow_blank=True) + expandableExplanation = serializers.CharField(required=False, allow_blank=True) + + +class ConversationSummarySerializer(serializers.Serializer): + id = serializers.UUIDField(source="uuid", read_only=True) + title = serializers.CharField(read_only=True) + farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True, allow_null=True) + message_count = serializers.IntegerField(read_only=True) + + +class ConversationCreateSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, allow_null=True) + title = serializers.CharField(required=False, allow_blank=True, max_length=255) + farm_context = serializers.JSONField(required=False) + + +class ChatHistoryMessageSerializer(serializers.Serializer): + message_id = serializers.UUIDField(read_only=True) + conversation_id = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) + role = serializers.ChoiceField(choices=Message.ROLE_CHOICES, read_only=True) + content = serializers.CharField(read_only=True, allow_blank=True) + sections = ChatSectionSerializer(many=True, read_only=True) + images = serializers.ListField(child=serializers.CharField(), read_only=True) + created_at = serializers.DateTimeField(read_only=True) + + +class ConversationMessagesSerializer(serializers.Serializer): + conversation_id = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) + messages = ChatHistoryMessageSerializer(many=True, read_only=True) + + +class ChatResponseDataSerializer(serializers.JSONField): + pass + + +class ConversationDeleteSerializer(serializers.Serializer): + conversation_id = serializers.UUIDField(read_only=True) + farm_uuid = serializers.UUIDField(read_only=True, allow_null=True) + + +class ChatPostSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True) + query = serializers.CharField(required=False, allow_blank=True, default="") + history = serializers.JSONField(required=False) + image_urls = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + images = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + conversation_id = serializers.UUIDField(required=False) + + def validate(self, attrs): + query = (attrs.get("query") or "").strip() + image_urls = attrs.get("image_urls") or [] + images = attrs.get("images") or [] + history = attrs.get("history", []) + + if isinstance(history, str): + try: + history = serializers.JSONField().to_internal_value(history) + except serializers.ValidationError as exc: + raise serializers.ValidationError({"history": exc.detail}) from exc + + if history in (None, ""): + history = [] + if not isinstance(history, list): + raise serializers.ValidationError({"history": ["History must be an array or a valid JSON array string."]}) + + if not query and not image_urls and not images: + raise serializers.ValidationError({"query": ["This field may not be blank unless an image is sent."]}) + + attrs["query"] = query + attrs["history"] = history + return attrs diff --git a/Modules/Backend/farm_ai_assistant/tests.py b/Modules/Backend/farm_ai_assistant/tests.py new file mode 100644 index 0000000..fd9c4f4 --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/tests.py @@ -0,0 +1,412 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory, force_authenticate +from unittest.mock import patch + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .models import Conversation, Message +from .views import ( + ChatDetailView, + ChatListCreateView, + ChatMessagesView, + ChatTaskCreateView, + ChatTaskStatusView, + ContextView, + ChatView, +) + + +@override_settings(USE_EXTERNAL_API_MOCK=True) +class FarmAiAssistantOptionalFarmUuidTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm 1", + ) + + def test_context_allows_missing_farm_uuid(self): + request = self.factory.get("/api/farm-ai-assistant/context/") + force_authenticate(request, user=self.user) + + response = ContextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertIsNone(response.data["data"]["farm_uuid"]) + + def test_chat_task_create_allows_missing_farm_uuid_for_landing_chat(self): + request = self.factory.post( + "/api/farm-ai-assistant/chat/task/", + {"content": "Give me a landing page recommendation"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatTaskCreateView.as_view()(request) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123") + self.assertIsNone(response.data["data"]["farm_uuid"]) + + conversation = Conversation.objects.get(uuid=response.data["data"]["conversation_id"]) + self.assertIsNone(conversation.farm) + self.assertEqual(conversation.owner_id, self.user.id) + + user_message = conversation.messages.get(role=Message.ROLE_USER) + self.assertIsNone(user_message.farm) + self.assertIsNone(user_message.raw_response["farm_uuid"]) + + def test_status_success_without_farm_uuid_persists_assistant_message(self): + conversation = Conversation.objects.create( + owner=self.user, + farm=None, + title="Landing chat", + farm_context={}, + ) + Message.objects.create( + conversation=conversation, + farm=None, + role=Message.ROLE_USER, + content="What should I plant?", + raw_response={ + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/", + "farm_uuid": None, + }, + ) + + request = self.factory.get("/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/") + force_authenticate(request, user=self.user) + + response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["task_id"], "farm-ai-chat-task-123") + self.assertEqual(response.data["data"]["status"], "SUCCESS") + self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid)) + self.assertIsNone(response.data["data"]["farm_uuid"]) + self.assertEqual(response.data["data"]["result"]["content"], "Here is the recommended plan.") + self.assertEqual(response.data["data"]["result"]["task_id"], "farm-ai-chat-task-123") + + assistant_message = ( + conversation.messages.filter(role=Message.ROLE_ASSISTANT) + .order_by("-created_at") + .first() + ) + self.assertIsNotNone(assistant_message) + self.assertIsNone(assistant_message.farm) + self.assertEqual(assistant_message.content, "Here is the recommended plan.") + self.assertIsNone(assistant_message.raw_response["farm_uuid"]) + self.assertEqual(assistant_message.raw_response["task_id"], "farm-ai-chat-task-123") + + def test_status_success_with_farm_uuid_still_works_for_farm_chat(self): + conversation = Conversation.objects.create( + owner=self.user, + farm=self.farm, + title="Farm chat", + farm_context={}, + ) + Message.objects.create( + conversation=conversation, + farm=self.farm, + role=Message.ROLE_USER, + content="What is the best irrigation plan?", + raw_response={ + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/", + "farm_uuid": str(self.farm.farm_uuid), + }, + ) + + request = self.factory.get( + f"/api/farm-ai-assistant/chat/task/farm-ai-chat-task-123/status/?farm_uuid={self.farm.farm_uuid}" + ) + force_authenticate(request, user=self.user) + + response = ChatTaskStatusView.as_view()(request, task_id="farm-ai-chat-task-123") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["conversation_id"], str(conversation.uuid)) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + + def test_chat_list_create_messages_and_delete_work_without_farm_uuid(self): + landing_conversation = Conversation.objects.create( + owner=self.user, + farm=None, + title="Landing chat", + farm_context={"source": "landing"}, + ) + Message.objects.create( + conversation=landing_conversation, + farm=None, + role=Message.ROLE_USER, + content="Hello from landing", + raw_response={"farm_uuid": None}, + ) + farm_conversation = Conversation.objects.create( + owner=self.user, + farm=self.farm, + title="Farm chat", + farm_context={}, + ) + Message.objects.create( + conversation=farm_conversation, + farm=self.farm, + role=Message.ROLE_USER, + content="Hello from farm", + raw_response={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + list_request = self.factory.get("/api/farm-ai-assistant/chats/") + force_authenticate(list_request, user=self.user) + list_response = ChatListCreateView.as_view()(list_request) + + self.assertEqual(list_response.status_code, 200) + self.assertEqual(len(list_response.data["data"]), 1) + self.assertEqual(list_response.data["data"][0]["id"], str(landing_conversation.uuid)) + self.assertIsNone(list_response.data["data"][0]["farm_uuid"]) + + create_request = self.factory.post( + "/api/farm-ai-assistant/chats/", + {"title": "New landing conversation"}, + format="json", + ) + force_authenticate(create_request, user=self.user) + create_response = ChatListCreateView.as_view()(create_request) + + self.assertEqual(create_response.status_code, 201) + self.assertIsNone(create_response.data["data"]["farm_uuid"]) + + created_conversation = Conversation.objects.get(uuid=create_response.data["data"]["id"]) + self.assertIsNone(created_conversation.farm) + + messages_request = self.factory.get( + f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/messages/" + ) + force_authenticate(messages_request, user=self.user) + messages_response = ChatMessagesView.as_view()( + messages_request, + conversation_id=landing_conversation.uuid, + ) + + self.assertEqual(messages_response.status_code, 200) + self.assertEqual(messages_response.data["data"]["conversation_id"], str(landing_conversation.uuid)) + self.assertIsNone(messages_response.data["data"]["farm_uuid"]) + self.assertEqual(len(messages_response.data["data"]["messages"]), 1) + self.assertIsNone(messages_response.data["data"]["messages"][0]["farm_uuid"]) + + delete_request = self.factory.delete(f"/api/farm-ai-assistant/chats/{landing_conversation.uuid}/") + force_authenticate(delete_request, user=self.user) + delete_response = ChatDetailView.as_view()( + delete_request, + conversation_id=landing_conversation.uuid, + ) + + self.assertEqual(delete_response.status_code, 200) + self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid)) + self.assertIsNone(delete_response.data["data"]["farm_uuid"]) + self.assertFalse(Conversation.objects.filter(uuid=landing_conversation.uuid).exists()) + + +@override_settings(USE_EXTERNAL_API_MOCK=True) +class FarmAiAssistantChatViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="chat-user", + password="secret123", + email="chat-user@example.com", + phone_number="09120000001", + ) + self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm Chat", + ) + + @patch("farm_ai_assistant.views.external_api_request") + def test_chat_reads_content_and_sections_from_nested_result_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "content": "برای خاک شما گندم و کلزا مناسب هستند.", + "sections": [ + { + "type": "chatTitle", + "title": "تناسب خاک برای محصولات مختلف", + }, + { + "type": "list", + "title": "محصولات مناسب", + "items": ["گندم", "کلزا"], + } + ], + "extra_field": {"confidence": 0.92}, + }, + ) + + request = self.factory.post( + "/api/farm-ai-assistant/chat/", + { + "farm_uuid": str(self.farm.farm_uuid), + "query": "خاک من واسه چه محصولاتی مناسبه", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["content"], "برای خاک شما گندم و کلزا مناسب هستند.") + self.assertEqual(response.data["data"]["extra_field"], {"confidence": 0.92}) + self.assertEqual(response.data["conversation_title"], "تناسب خاک برای محصولات مختلف") + self.assertEqual(response.data["data"]["sections"][1]["title"], "محصولات مناسب") + + assistant_message = Message.objects.filter(role=Message.ROLE_ASSISTANT).latest("created_at") + self.assertEqual(assistant_message.content, "برای خاک شما گندم و کلزا مناسب هستند.") + self.assertEqual(assistant_message.raw_response["sections"][1]["items"], ["گندم", "کلزا"]) + assistant_message.refresh_from_db() + self.assertEqual(assistant_message.conversation.title, "تناسب خاک برای محصولات مختلف") + + @patch("farm_ai_assistant.views.external_api_request") + def test_chat_returns_error_when_ai_payload_is_empty(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={}, + ) + + request = self.factory.post( + "/api/farm-ai-assistant/chat/", + { + "farm_uuid": str(self.farm.farm_uuid), + "query": "خاک من واسه چه محصولاتی مناسبه", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatView.as_view()(request) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.data["status"], "error") + self.assertIn("empty or invalid", response.data["data"]["message"]) + self.assertEqual(Message.objects.filter(role=Message.ROLE_ASSISTANT).count(), 0) + + @patch("farm_ai_assistant.views.external_api_request") + def test_chat_reads_sections_from_fenced_json_text_response(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data="""```json +{ + "answer": "بله، خاک شما برای کاشت گل رز مناسب است.", + "sections": [ + { + "type": "recommendation", + "title": "جمع‌بندی اصلی", + "content": "بله، خاک شما برای کاشت گل رز مناسب است." + }, + { + "type": "list", + "title": "نکات اجرایی", + "items": ["زهکشی خاک را بررسی کنید."] + } + ] +} +```""", + ) + + request = self.factory.post( + "/api/farm-ai-assistant/chat/", + { + "farm_uuid": str(self.farm.farm_uuid), + "query": "خاک من برای گل رز مناسبه؟", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["status"], "success") + self.assertEqual(response.data["data"]["answer"], "بله، خاک شما برای کاشت گل رز مناسب است.") + self.assertEqual(response.data["conversation_title"], "خاک") + self.assertEqual(len(response.data["data"]["sections"]), 2) + self.assertEqual(response.data["data"]["sections"][0]["title"], "جمع‌بندی اصلی") + self.assertEqual(response.data["data"]["sections"][1]["items"], ["زهکشی خاک را بررسی کنید."]) + + @patch("farm_ai_assistant.views.external_api_request") + def test_chat_does_not_change_existing_conversation_title_on_later_turns(self, mock_external_api_request): + conversation = Conversation.objects.create( + owner=self.user, + farm=self.farm, + title="عنوان اولیه", + farm_context={}, + ) + Message.objects.create( + conversation=conversation, + farm=self.farm, + role=Message.ROLE_USER, + content="پیام اول", + raw_response={}, + ) + Message.objects.create( + conversation=conversation, + farm=self.farm, + role=Message.ROLE_ASSISTANT, + content="پاسخ اول", + raw_response={"sections": [{"type": "chatTitle", "title": "عنوان اولیه"}]}, + ) + + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "sections": [ + { + "type": "chatTitle", + "title": "عنوان جدید که نباید ذخیره شود", + }, + { + "type": "recommendation", + "title": "پاسخ جدید", + "content": "این فقط پاسخ جدید است.", + }, + ] + }, + ) + + request = self.factory.post( + "/api/farm-ai-assistant/chat/", + { + "farm_uuid": str(self.farm.farm_uuid), + "conversation_id": str(conversation.uuid), + "query": "سوال دوم", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = ChatView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["conversation_title"], "عنوان اولیه") + conversation.refresh_from_db() + self.assertEqual(conversation.title, "عنوان اولیه") diff --git a/Modules/Backend/farm_ai_assistant/urls.py b/Modules/Backend/farm_ai_assistant/urls.py new file mode 100644 index 0000000..dd3581c --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + ChatDetailView, + ChatListCreateView, + ChatMessagesView, + ChatView, + ContextView, +) + +urlpatterns = [ + path("context/", ContextView.as_view(), name="farm-ai-assistant-context"), + path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), + path("chats/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"), + path("chats//", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"), + path("chats//messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"), +] diff --git a/Modules/Backend/farm_ai_assistant/views.py b/Modules/Backend/farm_ai_assistant/views.py new file mode 100644 index 0000000..00048ae --- /dev/null +++ b/Modules/Backend/farm_ai_assistant/views.py @@ -0,0 +1,615 @@ +"""Farm AI Assistant API views.""" + +import json +import logging +from copy import deepcopy + +from django.db.models import Count +from django.http import Http404 +from rest_framework import serializers, status +from rest_framework.exceptions import ParseError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema + +from config.swagger import status_response +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError +from farm_hub.models import FarmHub +from .defaults import CONTEXT_RESPONSE_TEMPLATE +from .models import Conversation, Message +from .serializers import ( + ChatPostSerializer, + ChatResponseDataSerializer, + ConversationCreateSerializer, + ConversationDeleteSerializer, + ConversationMessagesSerializer, + ConversationSummarySerializer, +) + + +logger = logging.getLogger(__name__) + + +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + return FarmAccessMixin._get_optional_farm(request, farm_uuid) + + @staticmethod + def _get_optional_farm(request, farm_uuid): + if not farm_uuid: + return None + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise Http404("Farm not found") from exc + + @staticmethod + def _farm_uuid_or_none(farm): + return str(farm.farm_uuid) if farm else None + + +class ContextView(FarmAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())}, + ) + def get(self, request): + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + data = deepcopy(CONTEXT_RESPONSE_TEMPLATE) + data["farm_uuid"] = self._farm_uuid_or_none(farm) + return Response( + {"status": "success", "data": data}, + status=status.HTTP_200_OK, + ) + + +class ConversationAccessMixin(FarmAccessMixin): + @staticmethod + def _is_non_empty_payload(payload): + if isinstance(payload, dict): + return bool(payload) + if isinstance(payload, list): + return bool(payload) + if isinstance(payload, str): + return bool(payload.strip()) + return payload is not None + + @staticmethod + def _parse_adapter_text_payload(adapter_data): + if not isinstance(adapter_data, str): + return adapter_data + + text = adapter_data.strip() + if not text: + return adapter_data + + if text.startswith("```"): + lines = text.splitlines() + if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```": + text = "\n".join(lines[1:-1]).strip() + + try: + return json.loads(text) + except (TypeError, ValueError): + logger.warning( + "Farm AI assistant text response could not be parsed as JSON: preview=%s", + text[:200], + ) + return adapter_data + + @staticmethod + def _generate_conversation_title(query): + normalized_query = (query or "").strip() + if not normalized_query: + return "Image" + first_word = normalized_query.split()[0].strip() + return (first_word or normalized_query or "New chat")[:255] + + @staticmethod + def _get_conversation(request, conversation_id, farm_uuid=None): + filters = {"uuid": conversation_id, "owner": request.user} + if farm_uuid: + filters["farm__farm_uuid"] = farm_uuid + else: + filters["farm__isnull"] = True + try: + return Conversation.objects.select_related("farm").get(**filters) + except Conversation.DoesNotExist as exc: + raise Http404("Conversation not found") from exc + + @staticmethod + def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "content", + "items", + "icon", + "primaryAction", + "frequency", + "amount", + "timing", + "validityPeriod", + "expandableExplanation", + } + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + def _get_or_create_conversation(self, request, validated): + conversation_id = validated.get("conversation_id") + farm = self._get_optional_farm(request, validated.get("farm_uuid")) + + if conversation_id: + conversation = self._get_conversation( + request, + conversation_id, + farm.farm_uuid if farm else None, + ) + return conversation + + return Conversation.objects.create( + owner=request.user, + farm=farm, + title=self._generate_conversation_title(validated.get("query", "")), + farm_context={}, + ) + + @staticmethod + def _serialize_history_messages(history): + normalized_history = [] + for item in history or []: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").strip() + content = str(item.get("content") or item.get("message") or "").strip() + if not role and not content: + continue + normalized_item = {} + if role: + normalized_item["role"] = role + if content: + normalized_item["content"] = content + if item.get("sections") is not None: + normalized_item["sections"] = item.get("sections") + normalized_history.append(normalized_item) + return normalized_history + + @staticmethod + def _build_adapter_payload(request, validated, conversation): + payload = { + "farm_uuid": str(conversation.farm.farm_uuid) if conversation.farm else "", + "query": validated.get("query", ""), + "history": ConversationAccessMixin._serialize_history_messages(validated.get("history", [])), + "image_urls": validated.get("image_urls", []), + "images": validated.get("images", []), + "conversation_id": str(conversation.uuid), + "user_id": request.user.id, + } + return payload + + @staticmethod + def _attach_uploaded_files(payload, uploaded_images): + if not uploaded_images: + return payload + + files = [] + for uploaded_image in uploaded_images: + files.append( + ( + "images", + ( + uploaded_image.name, + uploaded_image, + getattr(uploaded_image, "content_type", "application/octet-stream"), + ), + ) + ) + + multipart_payload = dict(payload) + multipart_payload["history"] = json.dumps(payload.get("history", []), ensure_ascii=False) + multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False) + multipart_payload["__files__"] = files + return multipart_payload + + @staticmethod + def _parse_json_array(value): + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, list) else None + + def _collect_uploaded_images(self, request): + uploaded_images = [] + single_image = request.FILES.get("image") + if single_image is not None: + uploaded_images.append(single_image) + uploaded_images.extend(request.FILES.getlist("images")) + return uploaded_images + + def _merge_history(self, validated, conversation): + provided_history = validated.get("history", []) + if provided_history: + return self._serialize_history_messages(provided_history) + + existing_messages = conversation.messages.order_by("created_at") + return [ + { + "role": message.role, + "content": message.content, + **( + {"sections": message.raw_response.get("sections", [])} + if message.role == Message.ROLE_ASSISTANT and isinstance(message.raw_response, dict) + else {} + ), + } + for message in existing_messages + if message.content + or ( + message.role == Message.ROLE_ASSISTANT + and isinstance(message.raw_response, dict) + and message.raw_response.get("sections") + ) + ] + + def _prepare_chat_input(self, request): + mutable_data = request.data.copy() + + for field_name in ("message", "content", "title", "farm_context"): + if field_name in mutable_data: + mutable_data.pop(field_name) + + if "history" in mutable_data: + parsed_history = self._parse_json_array(mutable_data.get("history")) + if parsed_history is not None: + mutable_data["history"] = parsed_history + + if "image_urls" in mutable_data and isinstance(mutable_data.get("image_urls"), str): + parsed_urls = self._parse_json_array(mutable_data.get("image_urls")) + if parsed_urls is not None: + mutable_data.setlist("image_urls", parsed_urls) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("image_urls", parsed_urls) + + if "images" in mutable_data and isinstance(mutable_data.get("images"), str): + parsed_images = self._parse_json_array(mutable_data.get("images")) + if parsed_images is not None: + mutable_data.setlist("images", parsed_images) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("images", parsed_images) + + return mutable_data + + @staticmethod + def _extract_message_content(payload): + if not isinstance(payload, dict): + return "" + + for key in ("content", "body", "message", "answer", "text"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + sections = payload.get("sections") + if isinstance(sections, list): + for section in sections: + if not isinstance(section, dict): + continue + value = section.get("content") + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + @staticmethod + def _extract_chat_title(payload): + if not isinstance(payload, dict): + return "" + + sections = payload.get("sections") + if not isinstance(sections, list): + return "" + + for section in sections: + if not isinstance(section, dict): + continue + if section.get("type") != "chatTitle": + continue + title = section.get("title") + if isinstance(title, str) and title.strip(): + return title.strip()[:255] + return "" + + def _extract_assistant_payload(self, adapter_data, conversation): + adapter_data = self._parse_adapter_text_payload(adapter_data) + + logger.warning( + "Farm AI assistant parsing response: conversation_id=%s adapter_type=%s adapter_keys=%s", + str(conversation.uuid), + type(adapter_data).__name__, + sorted(adapter_data.keys()) if isinstance(adapter_data, dict) else None, + ) + + logger.warning( + "Farm AI assistant final parsed payload: conversation_id=%s payload_type=%s is_non_empty=%s", + str(conversation.uuid), + type(adapter_data).__name__, + self._is_non_empty_payload(adapter_data), + ) + return adapter_data + + @staticmethod + def _serialize_chat_message(message): + raw_response = message.raw_response if isinstance(message.raw_response, dict) else {} + sections = raw_response.get("sections") if message.role == Message.ROLE_ASSISTANT else [] + return { + "message_id": str(message.uuid), + "conversation_id": str(message.conversation.uuid), + "farm_uuid": ConversationAccessMixin._farm_uuid_or_none(message.farm), + "role": message.role, + "content": message.content, + "sections": ConversationAccessMixin._normalize_sections(sections), + "images": message.images if isinstance(message.images, list) else [], + "created_at": message.created_at, + } + +class ChatListCreateView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))}, + ) + def get(self, request): + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + conversations = ( + Conversation.objects.filter(owner=request.user, farm=farm) + .annotate(message_count=Count("messages")) + .order_by("-updated_at", "-created_at") + ) + serializer = ConversationSummarySerializer(conversations, many=True) + return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm AI Assistant"], + request=ConversationCreateSerializer, + responses={201: status_response("FarmAiAssistantConversationCreateResponse", data=ConversationSummarySerializer())}, + ) + def post(self, request): + serializer = ConversationCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + farm = self._get_optional_farm(request, validated.get("farm_uuid")) + conversation = Conversation.objects.create( + owner=request.user, + farm=farm, + title=validated.get("title", "").strip() or "New chat", + farm_context=validated.get("farm_context") or {}, + ) + + response_serializer = ConversationSummarySerializer( + { + "uuid": conversation.uuid, + "farm": farm, + "message_count": 0, + } + ) + return Response({"status": "success", "data": response_serializer.data}, status=status.HTTP_201_CREATED) + + +class ChatMessagesView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())}, + ) + def get(self, request, conversation_id): + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None) + messages = conversation.messages.select_related("farm").all() + serialized_messages = [self._serialize_chat_message(message) for message in messages] + return Response( + { + "status": "success", + "data": { + "conversation_id": str(conversation.uuid), + "farm_uuid": self._farm_uuid_or_none(farm), + "messages": serialized_messages, + }, + }, + status=status.HTTP_200_OK, + ) + + +class ChatDetailView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"), + ], + responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())}, + ) + def delete(self, request, conversation_id): + farm = self._get_optional_farm(request, request.query_params.get("farm_uuid")) + conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None) + deleted_conversation_id = str(conversation.uuid) + deleted_farm_uuid = self._farm_uuid_or_none(conversation.farm) + conversation.delete() + return Response( + { + "status": "success", + "data": { + "conversation_id": deleted_conversation_id, + "farm_uuid": deleted_farm_uuid, + }, + }, + status=status.HTTP_200_OK, + ) + + +class ChatView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + request=ChatPostSerializer, + responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())}, + ) + def post(self, request): + try: + chat_input = self._prepare_chat_input(request) + except ParseError: + return Response( + { + "status": "error", + "data": { + "message": "Invalid JSON body. Use valid JSON and remove extra trailing characters.", + }, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = ChatPostSerializer(data=chat_input) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + conversation = self._get_or_create_conversation(request, validated) + is_first_chat_turn = not conversation.messages.exists() + history = self._merge_history(validated, conversation) + uploaded_images = self._collect_uploaded_images(request) + + user_message = Message.objects.create( + conversation=conversation, + farm=conversation.farm, + role=Message.ROLE_USER, + content=validated.get("query", ""), + images=validated.get("image_urls", []) + validated.get("images", []), + raw_response={ + "farm_uuid": self._farm_uuid_or_none(conversation.farm), + "history": history, + }, + ) + + adapter_payload = self._build_adapter_payload(request, validated, conversation) + adapter_payload["history"] = history + adapter_payload = self._attach_uploaded_files(adapter_payload, uploaded_images) + + try: + adapter_response = external_api_request( + "ai", + "/api/rag/chat/", + method="POST", + payload=adapter_payload, + ) + logger.warning( + "Farm AI assistant adapter response received: conversation_id=%s status_code=%s response_type=%s response_keys=%s", + str(conversation.uuid), + adapter_response.status_code, + type(adapter_response.data).__name__, + adapter_response + ) + if adapter_response.status_code >= 400: + return Response( + { + "status": "error", + "data": adapter_response.data, + }, + status=adapter_response.status_code, + ) + assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation) + if not self._is_non_empty_payload(assistant_payload): + logger.error( + "Farm AI assistant returned an empty payload: conversation_id=%s response_type=%s response_keys=%s", + str(conversation.uuid), + type(adapter_response.data).__name__, + sorted(adapter_response.data.keys()) if isinstance(adapter_response.data, dict) else None, + ) + return Response( + { + "status": "error", + "data": { + "message": "AI service returned an empty or invalid response.", + }, + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + response_status_code = adapter_response.status_code + except ExternalAPIRequestError as exc: + return Response( + { + "status": "error", + "data": { + "message": str(exc) or "External AI service is unavailable.", + }, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + assistant_message = Message.objects.create( + conversation=conversation, + farm=conversation.farm, + role=Message.ROLE_ASSISTANT, + content=self._extract_message_content(assistant_payload), + raw_response=assistant_payload if isinstance(assistant_payload, (dict, list)) else {}, + ) + + chat_title = self._extract_chat_title(assistant_payload) + if is_first_chat_turn and chat_title: + conversation.title = chat_title + conversation.save(update_fields=["title", "updated_at"]) + elif not conversation.title: + conversation.title = self._generate_conversation_title(validated.get("query", "")) + conversation.save(update_fields=["title", "updated_at"]) + else: + conversation.save(update_fields=["updated_at"]) + + return Response( + { + "status": "success", + "conversation_id": str(conversation.uuid), + "farm_uuid": self._farm_uuid_or_none(conversation.farm), + "data": assistant_payload, + "conversation_title": conversation.title, + }, + status=response_status_code, + ) diff --git a/Modules/Backend/farm_alerts/TRACKER_API_FRONTEND.md b/Modules/Backend/farm_alerts/TRACKER_API_FRONTEND.md new file mode 100644 index 0000000..3b22d7d --- /dev/null +++ b/Modules/Backend/farm_alerts/TRACKER_API_FRONTEND.md @@ -0,0 +1,436 @@ +# راهنمای فرانت برای API هشدارهای مزرعه + +این سند برای تیم فرانت نوشته شده تا بداند endpoint `tracker` چه ورودی‌ای می‌گیرد، چه کاری انجام می‌دهد، و response آن را چطور باید در UI مصرف کند. + +## Endpoint + +- `POST /api/farm-alerts/tracker/` + +## احراز هویت + +- این API نیاز به `Bearer Token` دارد. +- کاربر فقط به مزرعه‌های متعلق به خودش دسترسی دارد. + +## کاربرد API + +فرانت با ارسال alertهای جدید مربوط به یک مزرعه: + +- alertها را در بک‌اند ذخیره می‌کند +- notificationهای 3 روز اخیر همان مزرعه را هم به context اضافه می‌کند +- همه داده‌ها را برای AI می‌فرستد +- AI یک جمع‌بندی کوتاه، وضعیت کلی، و notificationهای مهم برمی‌گرداند +- notificationهای خروجی AI هم در دیتابیس ذخیره می‌شوند + +این endpoint هم برای تحلیل وضعیت هشدارها مناسب است، هم برای ساخت کارت summary، هم برای notification center. + +## Request Body + +فیلدهای ورودی: + +- `farm_uuid`: شناسه مزرعه - اجباری +- `alerts`: لیست هشدارهای جدید - اختیاری + +### ساختار هر alert + +هر آیتم داخل `alerts` می‌تواند این فیلدها را داشته باشد: + +- `alert_id`: شناسه یکتای هشدار در سمت منبع یا فرانت +- `level`: شدت هشدار مثل `info`، `warning`، `danger` +- `title`: عنوان هشدار +- `message`: متن هشدار +- `suggested_action`: اقدام پیشنهادی +- `source_metric_type`: نوع شاخص مثل `moisture` +- `timestamp`: زمان هشدار با فرمت datetime - اختیاری +- `payload`: داده تکمیلی JSON - اختیاری + +## نمونه request + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture" + } + ] +} +``` + +## نمونه curl + +```bash +curl -X POST \ + 'http://localhost:8000/api/farm-alerts/tracker/' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture" + } + ] + }' +``` + +## رفتار بک‌اند + +بعد از دریافت request: + +1. مزرعه را با `farm_uuid` پیدا می‌کند و ownership را چک می‌کند. +2. alertهای ارسالی را در جدول `farm_alerts` ذخیره می‌کند. +3. حداکثر 10 notification ثبت‌شده در 3 روز اخیر همان مزرعه را برمی‌دارد. +4. `alerts` جدید + `recent_notifications` را برای AI می‌فرستد. +5. notificationهای مهم تولیدشده توسط AI را در جدول `farm_notifications` ذخیره می‌کند. +6. response نهایی را به فرانت برمی‌گرداند. + +## ساختار response موفق + +response داخل envelope استاندارد برمی‌گردد: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "service_id": "farm_alerts", + "tracker": {}, + "headline": "بررسی رطوبت خاک در مزرعه", + "overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.", + "status_level": "warning", + "notifications": [], + "raw_llm_response": "...", + "structured_context": {} + } +} +``` + +## توضیح فیلدهای اصلی response + +### `farm_uuid` + +شناسه مزرعه‌ای که تحلیل برای آن انجام شده است. + +### `service_id` + +شناسه سرویس. فعلا مقدار آن `farm_alerts` است. + +### `tracker` + +بخش اصلی داده برای ساخت UI هشدارها. + +این بخش ممکن است شامل این فیلدها باشد: + +- `totalAlerts`: تعداد کل alertهای فعلی +- `alerts`: لیست alertهای تحلیل‌شده +- `alertStats`: آمار خلاصه برای کارت‌ها +- `alertClusters`: گروه‌بندی alertها +- `mostCriticalIssue`: مهم‌ترین هشدار فعلی +- `prioritizedAlertSummaries`: خلاصه‌های اولویت‌دار +- `recommendedOperationalActions`: اقدام‌های عملیاتی پیشنهادی +- `humanReadableExplanations`: توضیح‌های متنی ساده برای کاربر + +### `headline` + +تیتر کوتاه برای بالای کارت یا صفحه. + +### `overview` + +جمع‌بندی کوتاه و اجرایی برای کاربر. + +### `status_level` + +وضعیت کلی تحلیل برای رنگ‌بندی UI. + +مقادیر معمول: + +- `info` +- `warning` +- `error` +- `success` + +### `notifications` + +لیست notificationهای مهمی که AI تولید کرده و در دیتابیس ذخیره شده‌اند. + +هر notification ممکن است این فیلدها را داشته باشد: + +- `id`: شناسه دیتابیسی +- `uuid`: شناسه یکتا +- `farm_uuid`: شناسه مزرعه +- `since_id`: همان `id` برای برخی flowهای polling +- `endpoint`: منبع notification، اینجا معمولا `tracker` +- `title`: عنوان +- `message`: متن +- `level`: شدت +- `suggested_action`: اقدام پیشنهادی +- `source_alert_id`: شناسه alert اصلی +- `source_metric_type`: نوع شاخص +- `payload`: داده تکمیلی +- `is_read`: خوانده شده یا نه +- `metadata`: اطلاعات داخلی +- `created_at`: زمان ایجاد +- `updated_at`: زمان آخرین به‌روزرسانی + +### `raw_llm_response` + +پاسخ خام AI برای debug یا audit. + +برای UI اصلی معمولا لازم نیست مستقیم نمایش داده شود. + +### `structured_context` + +context تکمیلی که برای AI ساخته شده. + +ممکن است شامل این بخش‌ها باشد: + +- `farm_profile` +- `tracker` +- `forecasts` +- `incoming_alerts` + +این فیلد بیشتر برای debug، مانیتورینگ، یا صفحه‌های تخصصی مفید است. + +## استفاده پیشنهادی در فرانت + +### هدر صفحه یا کارت summary + +از این فیلدها استفاده کنید: + +- `headline` +- `overview` +- `status_level` + +### لیست هشدارهای فعلی + +از: + +- `tracker.alerts` + +### مهم‌ترین هشدار + +از: + +- `tracker.mostCriticalIssue` + +### کارت آمار هشدار + +از: + +- `tracker.totalAlerts` +- `tracker.alertStats` + +### اقدام‌های پیشنهادی + +از: + +- `tracker.recommendedOperationalActions` + +### توضیح ساده برای کاربر + +از: + +- `tracker.humanReadableExplanations` + +### notification center یا drawer + +از: + +- `notifications` + +## نمونه mapping برای فرانت + +```ts +const result = response.data.data; + +const headerTitle = result.headline; +const headerText = result.overview; +const severity = result.status_level; + +const totalAlerts = result.tracker.totalAlerts; +const alerts = result.tracker.alerts; +const stats = result.tracker.alertStats; +const criticalIssue = result.tracker.mostCriticalIssue; +const suggestedActions = result.tracker.recommendedOperationalActions; +const notifications = result.notifications; +``` + +## نمونه response واقعی + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "service_id": "farm_alerts", + "tracker": { + "totalAlerts": 1, + "alerts": [ + { + "metric_type": "moisture", + "title": "تنش رطوبتی", + "current_value": 42.3, + "threshold_value": 45, + "severity": "low", + "duration_hours": 2.8, + "duration": "3 ساعت", + "timestamp": "2026-04-28T20:31:39.594431+00:00", + "sensor_id": "11111111-1111-1111-1111-111111111111", + "zone_id": null, + "domain": "water_balance", + "direction": "below", + "unit": "%", + "icon": "tabler-droplet-half-2", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.", + "metadata": {} + } + ], + "alertStats": [ + { + "title": "تنش رطوبتی", + "count": "1", + "avatarColor": "info", + "avatarIcon": "tabler-droplet-half-2", + "severity": "low", + "topSummary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد." + } + ], + "alertClusters": [ + { + "domain": "water_balance", + "title": "تعادل آب", + "alert_count": 1, + "highest_severity": "low", + "primary_metric": "moisture", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "alert_ids": [ + "moisture:2026-04-28T20:31:39.594431+00:00" + ] + } + ], + "mostCriticalIssue": { + "metric_type": "moisture", + "title": "تنش رطوبتی", + "current_value": 42.3, + "threshold_value": 45, + "severity": "low", + "duration_hours": 2.8, + "duration": "3 ساعت", + "timestamp": "2026-04-28T20:31:39.594431+00:00", + "sensor_id": "11111111-1111-1111-1111-111111111111", + "zone_id": null, + "domain": "water_balance", + "direction": "below", + "unit": "%", + "icon": "tabler-droplet-half-2", + "summary": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.", + "recommended_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "explanation": "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است.", + "metadata": {} + }, + "prioritizedAlertSummaries": [ + "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد." + ], + "recommendedOperationalActions": [ + "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید." + ], + "humanReadableExplanations": [ + "رطوبت فعلی 42.3% به زیر آستانه 45.0% رسیده است و این وضعیت 3 ساعت ادامه داشته است." + ] + }, + "headline": "بررسی رطوبت خاک در مزرعه", + "overview": "افت خفیف رطوبت خاک گزارش شده است که نیاز به پایش دارد.", + "status_level": "warning", + "notifications": [ + { + "id": 1, + "uuid": "640e6187-49d9-4256-ad0d-18927712d496", + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "since_id": 1, + "endpoint": "tracker", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "level": "warning", + "suggested_action": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.", + "source_alert_id": "soil-moisture-001", + "source_metric_type": "moisture", + "payload": {}, + "is_read": false, + "metadata": { + "source": "farm_alerts_tracker_ai" + }, + "created_at": "2026-04-28T23:20:19.750658Z", + "updated_at": "2026-04-28T23:20:19.750719Z" + } + ], + "raw_llm_response": "{...}", + "structured_context": { + "incoming_alerts": [ + { + "alert_id": "soil-moisture-001", + "level": "warning", + "title": "افت رطوبت خاک", + "message": "رطوبت خاک کمتر از حد مطلوب گزارش شده است.", + "suggested_action": "آبیاری اصلاحی بررسی شود.", + "source_metric_type": "moisture", + "timestamp": null, + "payload": {} + } + ] + } + } +} +``` + +## خطاهای متداول + +### مزرعه پیدا نشد + +اگر `farm_uuid` متعلق به کاربر نباشد یا وجود نداشته باشد: + +```json +{ + "farm_uuid": [ + "Farm not found." + ] +} +``` + +### بدنه نامعتبر + +اگر فیلدهای نامعتبر بفرستید: + +```json +{ + "unexpected_field": [ + "This field is not allowed." + ] +} +``` + +### احراز هویت نامعتبر + +- در صورت نبود token یا نامعتبر بودن آن، پاسخ `401 Unauthorized` برمی‌گردد. + +## توصیه برای فرانت + +- برای هر alert یک `alert_id` پایدار بفرستید. +- اگر alert جدیدی ندارید، می‌توانید فقط `farm_uuid` بفرستید. +- از `headline` و `overview` برای summary UI استفاده کنید. +- از `notifications` برای notification list یا toast استفاده کنید. +- از `tracker.mostCriticalIssue` و `tracker.recommendedOperationalActions` برای CTA و نمایش اقدام فوری استفاده کنید. diff --git a/Modules/Backend/farm_alerts/__init__.py b/Modules/Backend/farm_alerts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farm_alerts/apps.py b/Modules/Backend/farm_alerts/apps.py new file mode 100644 index 0000000..4d111cf --- /dev/null +++ b/Modules/Backend/farm_alerts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmAlertsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farm_alerts" + verbose_name = "Farm Alerts" diff --git a/Modules/Backend/farm_alerts/defaults.py b/Modules/Backend/farm_alerts/defaults.py new file mode 100644 index 0000000..48d2343 --- /dev/null +++ b/Modules/Backend/farm_alerts/defaults.py @@ -0,0 +1,29 @@ +EMPTY_ALERT_TRACKER = { + "totalAlerts": 0, + "radialBarValue": 0, + "alertStats": [], + "status": "empty", + "source": "db", + "warnings": ["No active farm alerts were found."], +} + +EMPTY_ALERT_TIMELINE = { + "alerts": [], + "status": "empty", + "source": "db", + "warnings": ["No farm alert timeline entries were found."], +} + +EMPTY_ANOMALY_CARD = { + "anomalies": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted anomaly detections were found."], +} + +EMPTY_RECOMMENDATIONS = { + "recommendations": [], + "status": "empty", + "source": "db", + "warnings": ["No persisted farm recommendations were found."], +} diff --git a/Modules/Backend/farm_alerts/migrations/0001_initial.py b/Modules/Backend/farm_alerts/migrations/0001_initial.py new file mode 100644 index 0000000..3e287a5 --- /dev/null +++ b/Modules/Backend/farm_alerts/migrations/0001_initial.py @@ -0,0 +1,61 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("farm_hub", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="FarmAlert", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ("farm", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="farm_alerts", to="farm_hub.farmhub")), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("color", models.CharField(default="info", max_length=32)), + ("avatar_icon", models.CharField(blank=True, default="", max_length=64)), + ("avatar_color", models.CharField(blank=True, default="", max_length=32)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={"db_table": "farm_alerts", "ordering": ["-created_at"]}, + ), + migrations.CreateModel( + name="AnomalyDetection", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ("farm", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="anomalies", to="farm_hub.farmhub")), + ("sensor", models.CharField(max_length=255)), + ("value", models.CharField(max_length=64)), + ("expected", models.CharField(max_length=64)), + ("deviation", models.CharField(max_length=64)), + ("severity", models.CharField(default="warning", max_length=32)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={"db_table": "farm_anomaly_detections", "ordering": ["-created_at"]}, + ), + migrations.CreateModel( + name="Recommendation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)), + ("farm", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="recommendations", to="farm_hub.farmhub")), + ("title", models.CharField(max_length=255)), + ("subtitle", models.TextField(blank=True, default="")), + ("avatar_icon", models.CharField(blank=True, default="", max_length=64)), + ("avatar_color", models.CharField(blank=True, default="", max_length=32)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={"db_table": "farm_recommendations", "ordering": ["-created_at"]}, + ), + ] diff --git a/Modules/Backend/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py b/Modules/Backend/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py new file mode 100644 index 0000000..2aec3c8 --- /dev/null +++ b/Modules/Backend/farm_alerts/migrations/0002_alter_anomalydetection_severity_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-04-25 21:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('farm_alerts', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='anomalydetection', + name='severity', + field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='warning', max_length=32), + ), + migrations.AlterField( + model_name='farmalert', + name='color', + field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=32), + ), + ] diff --git a/Modules/Backend/farm_alerts/migrations/0003_farmalert_tracker_fields.py b/Modules/Backend/farm_alerts/migrations/0003_farmalert_tracker_fields.py new file mode 100644 index 0000000..18f8b63 --- /dev/null +++ b/Modules/Backend/farm_alerts/migrations/0003_farmalert_tracker_fields.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.15 on 2026-04-28 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("farm_alerts", "0002_alter_anomalydetection_severity_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="farmalert", + name="external_alert_id", + field=models.CharField(blank=True, db_index=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmalert", + name="occurred_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmalert", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmalert", + name="raw_alert", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmalert", + name="source_metric_type", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmalert", + name="suggested_action", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/Modules/Backend/farm_alerts/migrations/0004_farmalerttrackersnapshot.py b/Modules/Backend/farm_alerts/migrations/0004_farmalerttrackersnapshot.py new file mode 100644 index 0000000..03604f6 --- /dev/null +++ b/Modules/Backend/farm_alerts/migrations/0004_farmalerttrackersnapshot.py @@ -0,0 +1,48 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("farm_hub", "0001_initial"), + ("farm_alerts", "0003_farmalert_tracker_fields"), + ] + + operations = [ + migrations.CreateModel( + name="FarmAlertTrackerSnapshot", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("service_id", models.CharField(default="farm_alerts", max_length=64)), + ("tracker", models.JSONField(blank=True, default=dict)), + ("headline", models.CharField(blank=True, default="", max_length=255)), + ("overview", models.TextField(blank=True, default="")), + ( + "status_level", + models.CharField( + choices=[("info", "Info"), ("warning", "Warning"), ("error", "Error"), ("success", "Success")], + default="info", + max_length=32, + ), + ), + ("raw_llm_response", models.TextField(blank=True, default="")), + ("structured_context", models.JSONField(blank=True, default=dict)), + ("last_ai_synced_at", models.DateTimeField(blank=True, null=True)), + ("last_source_update_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="alert_tracker_snapshot", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_alert_tracker_snapshots", + }, + ), + ] diff --git a/Modules/Backend/farm_alerts/migrations/__init__.py b/Modules/Backend/farm_alerts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farm_alerts/mock_data.py b/Modules/Backend/farm_alerts/mock_data.py new file mode 100644 index 0000000..ec7117e --- /dev/null +++ b/Modules/Backend/farm_alerts/mock_data.py @@ -0,0 +1,101 @@ +ARM_ALERTS_TRACKER = { + "totalAlerts": 3, + "radialBarValue": 30, + "alertStats": [ + { + "title": "کمبود آب", + "count": "2", + "avatarColor": "error", + "avatarIcon": "tabler-droplet-half-2", + }, + { + "title": "ریسک قارچی", + "count": "1", + "avatarColor": "warning", + "avatarIcon": "tabler-mushroom", + }, + { + "title": "هشدار یخبندان", + "count": "0", + "avatarColor": "info", + "avatarIcon": "tabler-snowflake", + }, + ], +} + +FARM_ALERTS_TIMELINE = { + "alerts": [ + { + "title": "ریسک کمبود آب", + "description": "رطوبت خاک در عمق ۱۰ سانتی‌متر (۴۲٪) کمتر از حد بهینه است. پیش‌بینی: در صورت عدم آبیاری، تنش طی ۲ تا ۳ روز. توصیه: آبیاری ظرف ۲۴ ساعت.", + "time": "۱۵ دقیقه پیش", + "color": "warning", + }, + { + "title": "ریسک بیماری قارچی", + "description": "رطوبت بالا (۶۵٪) و دمای ۲۴ درجه شرایط مساعد برای رشد قارچ. استفاده از قارچ‌کش پیشگیرانه یا کاهش آبیاری را در نظر بگیرید.", + "time": "۱ ساعت پیش", + "color": "error", + }, + { + "title": "پیشنهاد آبیاری", + "description": "بازه بهینه آبیاری: ۶:۰۰ تا ۸:۰۰ صبح. حجم پیشنهادی: ۴۵۰ مترمکعب برای زون آ. بهبود راندمان مورد انتظار: ۱۲٪.", + "time": "۲ ساعت پیش", + "color": "info", + }, + { + "title": "بررسی شوری خاک", + "description": "مقدار هدایت الکتریکی ۱/۲ dS/m در محدوده مجاز است. نیازی به اقدام نیست. بررسی بعدی توصیه می‌شود ظرف ۵ روز.", + "time": "۴ ساعت پیش", + "color": "success", + }, + ] +} + +ANOMALY_DETECTION_CARD = { + "anomalies": [ + { + "sensor": "رطوبت خاک زون ۳", + "value": "38%", + "expected": "45-65%", + "deviation": "-12%", + "severity": "warning", + }, + { + "sensor": "pH بخش ۲", + "value": "5.2", + "expected": "6.0-7.0", + "deviation": "-0.8", + "severity": "error", + }, + ] +} + +RECOMMENDATIONS_LIST = { + "recommendations": [ + { + "title": "آبیاری: ۶:۰۰ تا ۸:۰۰ صبح", + "subtitle": "۴۵۰ مترمکعب برای زون آ. بدون آبیاری، عملکرد ممکن است حدود ۸٪ کاهش یابد.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + }, + { + "title": "کود: NPK 20-20-20", + "subtitle": "اعمال ۲۵ کیلوگرم در هکتار ظرف ۷ روز. کمبود نیتروژن فعلی در بخش ۲.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", + }, + { + "title": "قارچ‌کش: پیشگیرانه", + "subtitle": "رطوبت و دما مساعد قارچ. سمپاشی بر پایه مس را در نظر بگیرید.", + "avatarIcon": "tabler-mushroom", + "avatarColor": "warning", + }, + { + "title": "بازه برداشت: ۱۲ تا ۱۸ اکتبر", + "subtitle": "اوج رسیدگی حدود ۱۵ اکتبر. نیروی کار را متناسب برنامه‌ریزی کنید.", + "avatarIcon": "tabler-calendar-event", + "avatarColor": "info", + }, + ] +} diff --git a/Modules/Backend/farm_alerts/models.py b/Modules/Backend/farm_alerts/models.py new file mode 100644 index 0000000..8c313f8 --- /dev/null +++ b/Modules/Backend/farm_alerts/models.py @@ -0,0 +1,98 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub + + +SEVERITY_CHOICES = [ + ("info", "Info"), + ("warning", "Warning"), + ("error", "Error"), + ("success", "Success"), +] + + +class FarmAlert(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="farm_alerts", null=True, blank=True) + external_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + color = models.CharField(max_length=32, default="info", choices=SEVERITY_CHOICES) + suggested_action = models.TextField(blank=True, default="") + source_metric_type = models.CharField(max_length=255, blank=True, default="") + occurred_at = models.DateTimeField(null=True, blank=True) + payload = models.JSONField(default=dict, blank=True) + raw_alert = models.JSONField(default=dict, blank=True) + avatar_icon = models.CharField(max_length=64, blank=True, default="") + avatar_color = models.CharField(max_length=32, blank=True, default="") + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_alerts" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.title} ({self.color})" + + +class AnomalyDetection(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="anomalies", null=True, blank=True) + sensor = models.CharField(max_length=255) + value = models.CharField(max_length=64) + expected = models.CharField(max_length=64) + deviation = models.CharField(max_length=64) + severity = models.CharField(max_length=32, default="warning", choices=SEVERITY_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_anomaly_detections" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.sensor}: {self.value}" + + +class Recommendation(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="recommendations", null=True, blank=True) + title = models.CharField(max_length=255) + subtitle = models.TextField(blank=True, default="") + avatar_icon = models.CharField(max_length=64, blank=True, default="") + avatar_color = models.CharField(max_length=32, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "farm_recommendations" + ordering = ["-created_at"] + + def __str__(self): + return self.title + + +class FarmAlertTrackerSnapshot(models.Model): + farm = models.OneToOneField( + FarmHub, + on_delete=models.CASCADE, + related_name="alert_tracker_snapshot", + ) + service_id = models.CharField(max_length=64, default="farm_alerts") + tracker = models.JSONField(default=dict, blank=True) + headline = models.CharField(max_length=255, blank=True, default="") + overview = models.TextField(blank=True, default="") + status_level = models.CharField(max_length=32, default="info", choices=SEVERITY_CHOICES) + raw_llm_response = models.TextField(blank=True, default="") + structured_context = models.JSONField(default=dict, blank=True) + last_ai_synced_at = models.DateTimeField(null=True, blank=True) + last_source_update_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_alert_tracker_snapshots" + + def __str__(self): + return f"Tracker snapshot for {self.farm_id}" diff --git a/Modules/Backend/farm_alerts/serializers.py b/Modules/Backend/farm_alerts/serializers.py new file mode 100644 index 0000000..d981380 --- /dev/null +++ b/Modules/Backend/farm_alerts/serializers.py @@ -0,0 +1,99 @@ +from rest_framework import serializers + +from notifications.serializers import FarmNotificationSerializer + + +ALLOWED_TRACKER_FIELDS = {"farm_uuid", "alerts"} + + +class FarmAlertInputSerializer(serializers.Serializer): + alert_id = serializers.CharField(required=False, allow_blank=True) + level = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + message = serializers.CharField(required=False, allow_blank=True) + suggested_action = serializers.CharField(required=False, allow_blank=True) + source_metric_type = serializers.CharField(required=False, allow_blank=True) + timestamp = serializers.DateTimeField(required=False, allow_null=True) + payload = serializers.JSONField(required=False) + + +class FarmAlertsTrackerRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه.") + alerts = FarmAlertInputSerializer(many=True, required=False, default=list) + + def validate(self, attrs): + initial_keys = set(getattr(self, "initial_data", {}).keys()) + extra_fields = initial_keys - ALLOWED_TRACKER_FIELDS + if extra_fields: + raise serializers.ValidationError( + {field: ["This field is not allowed."] for field in sorted(extra_fields)} + ) + return attrs + + +class AlertTrackerAIResponseSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(read_only=True) + service_id = serializers.CharField() + tracker = serializers.JSONField() + headline = serializers.CharField(allow_blank=True) + overview = serializers.CharField(allow_blank=True) + status_level = serializers.CharField() + notifications = FarmNotificationSerializer(many=True) + raw_llm_response = serializers.CharField(allow_blank=True) + structured_context = serializers.JSONField() + + +class AlertStatSerializer(serializers.Serializer): + title = serializers.CharField() + count = serializers.CharField() + avatarColor = serializers.CharField() + avatarIcon = serializers.CharField() + + +class AlertTrackerSerializer(serializers.Serializer): + totalAlerts = serializers.IntegerField() + radialBarValue = serializers.IntegerField() + alertStats = AlertStatSerializer(many=True) + + +class AlertTimelineItemSerializer(serializers.Serializer): + title = serializers.CharField() + description = serializers.CharField() + time = serializers.CharField() + color = serializers.CharField() + + +class AlertTimelineSerializer(serializers.Serializer): + alerts = AlertTimelineItemSerializer(many=True) + + +class AnomalyItemSerializer(serializers.Serializer): + sensor = serializers.CharField() + value = serializers.CharField() + expected = serializers.CharField() + deviation = serializers.CharField() + severity = serializers.CharField() + + +class AnomalyDetectionSerializer(serializers.Serializer): + anomalies = AnomalyItemSerializer(many=True) + + +class RecommendationItemSerializer(serializers.Serializer): + title = serializers.CharField() + subtitle = serializers.CharField() + avatarIcon = serializers.CharField() + avatarColor = serializers.CharField() + + +class RecommendationsListSerializer(serializers.Serializer): + recommendations = RecommendationItemSerializer(many=True) + + +class CreateAlertSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه برای اتصال alert به مزرعه.") + title = serializers.CharField(max_length=255, help_text="عنوان هشدار.") + description = serializers.CharField(required=False, default="", allow_blank=True, help_text="توضیح هشدار.") + color = serializers.ChoiceField(choices=["info", "warning", "error", "success"], default="info", help_text="سطح یا رنگ هشدار.") + avatar_icon = serializers.CharField(required=False, default="", allow_blank=True, help_text="آیکون هشدار.") + avatar_color = serializers.CharField(required=False, default="", allow_blank=True, help_text="رنگ آواتار هشدار.") diff --git a/Modules/Backend/farm_alerts/services.py b/Modules/Backend/farm_alerts/services.py new file mode 100644 index 0000000..0ee4fbc --- /dev/null +++ b/Modules/Backend/farm_alerts/services.py @@ -0,0 +1,480 @@ +from collections import Counter +from copy import deepcopy +import json +import logging + +from django.utils import timezone + +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from notifications.models import FarmNotification +from notifications.services import create_notification_for_farm_uuid, get_recent_notifications_for_farm + +from .defaults import EMPTY_ALERT_TIMELINE, EMPTY_ALERT_TRACKER, EMPTY_ANOMALY_CARD, EMPTY_RECOMMENDATIONS +from .models import AnomalyDetection, FarmAlert, FarmAlertTrackerSnapshot, Recommendation + + +LEVEL_ALIAS_MAP = { + "danger": "error", + "critical": "error", + "warn": "warning", +} + +TRACKER_AI_NOTIFICATION_SOURCE = "farm_alerts_tracker_ai" +logger = logging.getLogger("farm_alerts") + + +class AlertService: + @staticmethod + def normalize_level(level): + normalized = str(level or "info").strip().lower() + normalized = LEVEL_ALIAS_MAP.get(normalized, normalized) + if normalized not in {"info", "warning", "error", "success"}: + return "info" + return normalized + + @staticmethod + def create_alert( + title: str, + description: str = "", + color: str = "info", + avatar_icon: str = "", + avatar_color: str = "", + farm_uuid=None, + ) -> FarmAlert: + farm = None + if farm_uuid: + try: + farm = FarmHub.objects.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist: + pass + + alert = FarmAlert.objects.create( + farm=farm, + title=title, + description=description, + color=AlertService.normalize_level(color), + avatar_icon=avatar_icon, + avatar_color=avatar_color, + ) + + AlertService._send_notification(alert, farm) + return alert + + @staticmethod + def persist_incoming_alerts(*, farm, alerts): + saved_alerts = [] + for alert_data in alerts: + title = alert_data.get("title") or alert_data.get("message") or "Incoming alert" + level = AlertService.normalize_level(alert_data.get("level")) + saved_alerts.append( + FarmAlert.objects.create( + farm=farm, + external_alert_id=alert_data.get("alert_id", ""), + title=title[:255], + description=alert_data.get("message", ""), + color=level, + suggested_action=alert_data.get("suggested_action", ""), + source_metric_type=alert_data.get("source_metric_type", ""), + occurred_at=alert_data.get("timestamp"), + payload=alert_data.get("payload") or {}, + raw_alert=alert_data, + is_active=level != "success", + ) + ) + return saved_alerts + + @staticmethod + def _send_notification(alert: FarmAlert, farm) -> None: + if farm is None: + return + + FarmNotification.objects.create( + farm=farm, + title=alert.title, + message=alert.description, + level=alert.color, + source_alert_id=alert.external_alert_id, + source_metric_type=alert.source_metric_type, + suggested_action=alert.suggested_action, + payload=alert.payload, + metadata={"alert_uuid": str(alert.uuid), "color": alert.color}, + ) + + +def serialize_notifications_for_ai(*, farm, since_days=3, limit=5): + notifications = get_recent_notifications_for_farm(farm=farm, since_days=since_days, limit=limit) + notifications = [item for item in notifications if item.metadata.get("source") != TRACKER_AI_NOTIFICATION_SOURCE] + return [ + { + "id": notification.id, + "farm_uuid": str(notification.farm.farm_uuid), + "endpoint": notification.endpoint, + "level": notification.level, + "title": notification.title, + "message": notification.message, + "suggested_action": notification.suggested_action, + "source_alert_id": notification.source_alert_id, + "source_metric_type": notification.source_metric_type, + "payload": notification.payload, + "created_at": notification.created_at.isoformat(), + "updated_at": notification.updated_at.isoformat(), + } + for notification in notifications + ] + + +def save_tracker_notifications(*, farm_uuid, notifications): + saved_notifications = [] + for notification_data in notifications: + title = notification_data.get("title") or "" + message = notification_data.get("message") or "" + if not title and not message: + continue + + source_alert_id = notification_data.get("source_alert_id", "") + existing = FarmNotification.objects.filter( + farm__farm_uuid=farm_uuid, + endpoint="tracker", + title=title, + message=message, + source_alert_id=source_alert_id, + ).first() + if existing: + saved_notifications.append(existing) + continue + + saved_notifications.append( + create_notification_for_farm_uuid( + farm_uuid=farm_uuid, + endpoint="tracker", + title=title, + message=message, + level=AlertService.normalize_level(notification_data.get("level")), + suggested_action=notification_data.get("suggested_action", ""), + source_alert_id=source_alert_id, + source_metric_type=notification_data.get("source_metric_type", ""), + payload=notification_data.get("payload") or {}, + metadata={"source": TRACKER_AI_NOTIFICATION_SOURCE}, + ) + ) + return saved_notifications + + +def build_tracker_context(*, farm): + recent_notifications = serialize_notifications_for_ai(farm=farm, since_days=3, limit=5) + payload = {"farm_uuid": str(farm.farm_uuid)} + + if recent_notifications: + counts = Counter( + AlertService.normalize_level(notification.get("level")) + for notification in recent_notifications + if notification.get("level") + ) + payload["recent_notifications"] = recent_notifications + payload["structured_context"] = { + "farm_uuid": str(farm.farm_uuid), + "notifications_count": len(recent_notifications), + "recent_notifications_count": len(recent_notifications), + "recent_notifications_window_days": 3, + "recent_notifications_limit": 5, + "notification_levels": dict(counts), + } + + return payload + + +def serialize_alerts_for_ai(*, farm, since=None, limit=50): + queryset = FarmAlert.objects.filter(farm=farm).order_by("-created_at", "-id") + if since is not None: + queryset = queryset.filter(created_at__gt=since) + + alerts = queryset[:limit] + return [ + { + "alert_id": alert.external_alert_id, + "level": alert.color, + "title": alert.title, + "message": alert.description, + "suggested_action": alert.suggested_action, + "source_metric_type": alert.source_metric_type, + "timestamp": alert.occurred_at.isoformat() if alert.occurred_at else None, + "payload": alert.payload, + } + for alert in alerts + ] + + +def get_tracker_notifications(*, farm, limit=10): + return list( + FarmNotification.objects.filter(farm=farm, endpoint="tracker") + .order_by("-created_at", "-id")[:limit] + ) + + +def get_tracker_source_updated_at(*, farm): + latest_alert = FarmAlert.objects.filter(farm=farm).order_by("-created_at", "-id").values_list("created_at", flat=True).first() + latest_notification = ( + FarmNotification.objects.filter(farm=farm) + .exclude(metadata__source=TRACKER_AI_NOTIFICATION_SOURCE) + .order_by("-updated_at", "-id") + .values_list("updated_at", flat=True) + .first() + ) + candidates = [item for item in (latest_alert, latest_notification) if item is not None] + if not candidates: + return None + return max(candidates) + + +def get_or_create_tracker_snapshot(*, farm): + snapshot, _ = FarmAlertTrackerSnapshot.objects.get_or_create(farm=farm) + return snapshot + + +def update_tracker_snapshot(*, farm, adapter_payload, source_updated_at): + snapshot = get_or_create_tracker_snapshot(farm=farm) + notifications_payload = adapter_payload.get("notifications") or [] + save_tracker_notifications(farm_uuid=farm.farm_uuid, notifications=notifications_payload) + + raw_llm_response = adapter_payload.get("raw_llm_response", "") + if not raw_llm_response: + raw_llm_response = json.dumps(adapter_payload, ensure_ascii=False) + + snapshot.service_id = adapter_payload.get("service_id", "farm_alerts") + snapshot.tracker = adapter_payload.get("tracker") or {} + snapshot.headline = adapter_payload.get("headline", "") + snapshot.overview = adapter_payload.get("overview", "") + snapshot.status_level = AlertService.normalize_level(adapter_payload.get("status_level")) + snapshot.raw_llm_response = raw_llm_response + snapshot.structured_context = adapter_payload.get("structured_context") or {} + snapshot.last_ai_synced_at = timezone.now() + snapshot.last_source_update_at = source_updated_at + snapshot.save( + update_fields=[ + "service_id", + "tracker", + "headline", + "overview", + "status_level", + "raw_llm_response", + "structured_context", + "last_ai_synced_at", + "last_source_update_at", + "updated_at", + ] + ) + return snapshot + + +def build_tracker_response_from_snapshot(*, farm): + snapshot = FarmAlertTrackerSnapshot.objects.filter(farm=farm).first() + notifications = get_tracker_notifications(farm=farm, limit=10) + if snapshot is None: + return { + "farm_uuid": str(farm.farm_uuid), + "service_id": "farm_alerts", + "tracker": {}, + "headline": "", + "overview": "", + "status_level": "info", + "notifications": notifications, + "raw_llm_response": "", + "structured_context": {}, + } + + return { + "farm_uuid": str(farm.farm_uuid), + "service_id": snapshot.service_id, + "tracker": snapshot.tracker or {}, + "headline": snapshot.headline, + "overview": snapshot.overview, + "status_level": AlertService.normalize_level(snapshot.status_level), + "notifications": notifications, + "raw_llm_response": snapshot.raw_llm_response, + "structured_context": snapshot.structured_context or {}, + } + + +def sync_farm_tracker_with_ai(*, farm): + snapshot = FarmAlertTrackerSnapshot.objects.filter(farm=farm).first() + source_updated_at = get_tracker_source_updated_at(farm=farm) + if source_updated_at is None: + logger.info( + "farm=%s tracker sync proceeding without source data snapshot_exists=%s", + farm.farm_uuid, + snapshot is not None, + ) + + if ( + source_updated_at is not None + and snapshot is not None + and snapshot.last_source_update_at is not None + and source_updated_at <= snapshot.last_source_update_at + ): + logger.info( + "farm=%s tracker sync skipped: no changes source_updated_at=%s last_source_update_at=%s", + farm.farm_uuid, + source_updated_at, + snapshot.last_source_update_at, + ) + return {"farm_uuid": str(farm.farm_uuid), "status": "skipped", "reason": "no_changes"} + + tracker_payload = build_tracker_context(farm=farm) + logger.info( + "farm=%s tracker sync sending AI request recent_notifications=%s payload=%s", + farm.farm_uuid, + len(tracker_payload.get("recent_notifications", [])), + tracker_payload, + ) + adapter_response = external_api_request( + "ai", + "/api/farm-alerts/tracker/", + method="POST", + payload=tracker_payload, + ) + if adapter_response.status_code >= 400: + logger.warning( + "farm=%s tracker sync failed status_code=%s response=%s", + farm.farm_uuid, + adapter_response.status_code, + adapter_response.data, + ) + raise ValueError(f"AI tracker sync failed with status {adapter_response.status_code}.") + + adapter_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + logger.info( + "farm=%s tracker sync received AI response status_code=%s response=%s", + farm.farm_uuid, + adapter_response.status_code, + adapter_data, + ) + payload = adapter_data.get("data") + if isinstance(payload, dict) and isinstance(payload.get("result"), dict): + payload = payload["result"] + elif not isinstance(payload, dict): + payload = adapter_data.get("result") if isinstance(adapter_data.get("result"), dict) else adapter_data + logger.info( + "farm=%s tracker sync normalized AI payload=%s", + farm.farm_uuid, + payload, + ) + + update_tracker_snapshot( + farm=farm, + adapter_payload=payload or {}, + source_updated_at=source_updated_at, + ) + logger.info("farm=%s tracker sync completed successfully", farm.farm_uuid) + return {"farm_uuid": str(farm.farm_uuid), "status": "synced"} + + +def sync_all_farm_alert_trackers(): + farms = FarmHub.objects.all().order_by("id") + logger.info("farm alerts sync discovered %s farm(s) to process", farms.count()) + results = [] + for farm in farms: + results.append(sync_farm_tracker_with_ai(farm=farm)) + return {"processed": len(results), "results": results} +def get_alert_tracker_data(farm=None): + if farm is None: + return deepcopy(EMPTY_ALERT_TRACKER) + + alerts = list(FarmAlert.objects.filter(farm=farm, is_active=True)[:20]) + if not alerts: + return deepcopy(EMPTY_ALERT_TRACKER) + + counts = Counter(alert.title for alert in alerts) + alert_stats = [] + for title, count in counts.most_common(3): + sample = next((alert for alert in alerts if alert.title == title), None) + alert_stats.append( + { + "title": title, + "count": str(count), + "avatarColor": sample.color if sample else "info", + "avatarIcon": sample.avatar_icon or "tabler-bell", + } + ) + + return { + "totalAlerts": len(alerts), + "radialBarValue": min(len(alerts) * 10, 100), + "alertStats": alert_stats, + "status": "success", + "source": "db", + "warnings": [], + } + + +def get_alert_timeline_data(farm=None): + if farm is None: + return deepcopy(EMPTY_ALERT_TIMELINE) + + alerts = list(FarmAlert.objects.filter(farm=farm)[:10]) + if not alerts: + return deepcopy(EMPTY_ALERT_TIMELINE) + + return { + "alerts": [ + { + "title": alert.title, + "description": alert.description, + "time": alert.created_at.strftime("%Y-%m-%d %H:%M"), + "color": alert.color, + } + for alert in alerts + ], + "status": "success", + "source": "db", + "warnings": [], + } + + +def get_anomaly_detection_data(farm=None): + if farm is None: + return deepcopy(EMPTY_ANOMALY_CARD) + + anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10]) + if not anomalies: + return deepcopy(EMPTY_ANOMALY_CARD) + + return { + "anomalies": [ + { + "sensor": anomaly.sensor, + "value": anomaly.value, + "expected": anomaly.expected, + "deviation": anomaly.deviation, + "severity": anomaly.severity, + } + for anomaly in anomalies + ], + "status": "success", + "source": "db", + "warnings": [], + } + + +def get_recommendations_list_data(farm=None): + if farm is None: + return deepcopy(EMPTY_RECOMMENDATIONS) + + recommendations = list(Recommendation.objects.filter(farm=farm)[:10]) + if not recommendations: + return deepcopy(EMPTY_RECOMMENDATIONS) + + return { + "recommendations": [ + { + "title": recommendation.title, + "subtitle": recommendation.subtitle, + "avatarIcon": recommendation.avatar_icon or "tabler-bulb", + "avatarColor": recommendation.avatar_color or "info", + } + for recommendation in recommendations + ], + "status": "success", + "source": "db", + "warnings": [], + } diff --git a/Modules/Backend/farm_alerts/tasks.py b/Modules/Backend/farm_alerts/tasks.py new file mode 100644 index 0000000..485528b --- /dev/null +++ b/Modules/Backend/farm_alerts/tasks.py @@ -0,0 +1,21 @@ +import logging + +from celery import shared_task + +from .services import sync_all_farm_alert_trackers + +logger = logging.getLogger("farm_alerts") + + +@shared_task( + bind=True, + autoretry_for=(Exception,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 3}, +) +def sync_farm_alert_trackers(self): + logger.info("farm alerts periodic sync task started task_id=%s", getattr(self.request, "id", "")) + result = sync_all_farm_alert_trackers() + logger.info("farm alerts periodic sync task finished result=%s", result) + return result diff --git a/Modules/Backend/farm_alerts/tests.py b/Modules/Backend/farm_alerts/tests.py new file mode 100644 index 0000000..a4cc1ec --- /dev/null +++ b/Modules/Backend/farm_alerts/tests.py @@ -0,0 +1,211 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import SimpleTestCase, TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from notifications.models import FarmNotification + +from .models import FarmAlert, FarmAlertTrackerSnapshot +from .serializers import FarmAlertsTrackerRequestSerializer +from .services import sync_farm_tracker_with_ai +from .views import AlertTrackerView + + +class FarmAlertsTrackerRequestSerializerTests(SimpleTestCase): + def test_accepts_farm_uuid_and_optional_alerts(self): + serializer = FarmAlertsTrackerRequestSerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "alerts": [], + } + ) + + self.assertTrue(serializer.is_valid(), serializer.errors) + + def test_rejects_extra_fields(self): + serializer = FarmAlertsTrackerRequestSerializer( + data={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "unexpected": True, + } + ) + + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors["unexpected"][0], "This field is not allowed.") + + +class FarmAlertsTrackerViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farm-alerts-user", + password="secret123", + email="farm-alerts@example.com", + phone_number="09120000999", + ) + self.other_user = get_user_model().objects.create_user( + username="farm-alerts-other", + password="secret123", + email="farm-alerts-other@example.com", + phone_number="09120000998", + ) + self.farm_type = FarmType.objects.create(name="مرکبات") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm Alerts") + + def test_tracker_returns_cached_snapshot_without_accepting_alerts(self): + FarmNotification.objects.create( + farm=self.farm, + endpoint="tracker", + title="AI alert", + message="Cached notification", + level="warning", + ) + FarmAlertTrackerSnapshot.objects.create( + farm=self.farm, + headline="وضعیت هشدارها", + overview="دو مورد نیاز به پیگیری دارد.", + status_level="warning", + tracker={"active": 2}, + raw_llm_response='{"headline":"cached"}', + structured_context={"source": "ai"}, + ) + + request = self.factory.post( + "/api/farm-alerts/tracker/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["headline"], "وضعیت هشدارها") + self.assertEqual(response.data["data"]["status_level"], "warning") + self.assertEqual(len(response.data["data"]["notifications"]), 1) + self.assertEqual(response.data["data"]["notifications"][0]["endpoint"], "tracker") + self.assertEqual(response.data["meta"]["flow_type"], "cached_snapshot") + self.assertTrue(response.data["meta"]["cached"]) + self.assertEqual(response.data["meta"]["ownership"], "backend") + + def test_tracker_limits_cached_notifications_to_ten(self): + for index in range(12): + FarmNotification.objects.create(farm=self.farm, endpoint="tracker", title=f"Notification {index}", message="msg") + FarmAlertTrackerSnapshot.objects.create(farm=self.farm) + + request = self.factory.post( + "/api/farm-alerts/tracker/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["data"]["notifications"]), 10) + + def test_tracker_rejects_unowned_farm(self): + request = self.factory.post( + "/api/farm-alerts/tracker/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.other_user) + + response = AlertTrackerView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["farm_uuid"][0], "Farm not found.") + + @patch("farm_alerts.services.external_api_request") + def test_sync_task_sends_last_five_notifications_to_ai_and_updates_snapshot(self, mock_external_api_request): + for index in range(6): + FarmNotification.objects.create( + farm=self.farm, + endpoint="irrigation", + title=f"Irrigation reminder {index}", + message=f"Run irrigation cycle {index}", + level="info" if index % 2 == 0 else "warning", + ) + FarmNotification.objects.create( + farm=self.farm, + endpoint="irrigation", + title="AI generated tracker notice", + message="Should be excluded from AI input", + level="info", + metadata={"source": "farm_alerts_tracker_ai"}, + ) + + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "headline": "وضعیت جدید", + "overview": "یک تغییر جدید شناسایی شد.", + "status_level": "warning", + "tracker": {"active": 1}, + "notifications": [ + { + "title": "افت رطوبت خاک", + "message": "تنش رطوبتی ادامه دارد.", + "level": "warning", + "suggested_action": "آبیاری جبرانی انجام شود.", + "source_alert_id": "soil-1", + } + ], + "structured_context": {"source": "ai"}, + } + }, + ) + + result = sync_farm_tracker_with_ai(farm=self.farm) + + self.assertEqual(result["status"], "synced") + mock_external_api_request.assert_called_once() + outbound_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(outbound_payload["farm_uuid"], str(self.farm.farm_uuid)) + self.assertNotIn("alerts", outbound_payload) + self.assertEqual(len(outbound_payload["recent_notifications"]), 5) + self.assertEqual(outbound_payload["recent_notifications"][0]["title"], "Irrigation reminder 5") + self.assertEqual(outbound_payload["recent_notifications"][-1]["title"], "Irrigation reminder 1") + + snapshot = FarmAlertTrackerSnapshot.objects.get(farm=self.farm) + self.assertEqual(snapshot.headline, "وضعیت جدید") + self.assertEqual(snapshot.status_level, "warning") + self.assertIsNotNone(snapshot.last_ai_synced_at) + self.assertIsNotNone(snapshot.last_source_update_at) + + persisted_notification = FarmNotification.objects.filter( + farm=self.farm, + title="افت رطوبت خاک", + endpoint="tracker", + ).latest("id") + self.assertEqual(persisted_notification.metadata["source"], "farm_alerts_tracker_ai") + + @patch("farm_alerts.services.external_api_request") + def test_sync_task_skips_ai_when_no_new_data_exists(self, mock_external_api_request): + snapshot = FarmAlertTrackerSnapshot.objects.create( + farm=self.farm, + last_ai_synced_at=timezone.now(), + last_source_update_at=timezone.now(), + ) + notification = FarmNotification.objects.create( + farm=self.farm, + endpoint="irrigation", + title="Irrigation reminder", + message="Run irrigation cycle", + level="warning", + ) + FarmNotification.objects.filter(id=notification.id).update(updated_at=snapshot.last_source_update_at - timedelta(minutes=1)) + + result = sync_farm_tracker_with_ai(farm=self.farm) + + self.assertEqual(result["status"], "skipped") + self.assertEqual(result["reason"], "no_changes") + mock_external_api_request.assert_not_called() diff --git a/Modules/Backend/farm_alerts/urls.py b/Modules/Backend/farm_alerts/urls.py new file mode 100644 index 0000000..3667ae0 --- /dev/null +++ b/Modules/Backend/farm_alerts/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import AlertTrackerView + +urlpatterns = [ + path("tracker/", AlertTrackerView.as_view(), name="farm-alerts-tracker"), +] diff --git a/Modules/Backend/farm_alerts/views.py b/Modules/Backend/farm_alerts/views.py new file mode 100644 index 0000000..56cac81 --- /dev/null +++ b/Modules/Backend/farm_alerts/views.py @@ -0,0 +1,83 @@ +import logging + +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.integration_contract import build_integration_meta +from config.swagger import code_response +from farm_hub.models import FarmHub + +from .serializers import AlertTrackerAIResponseSerializer, FarmAlertsTrackerRequestSerializer +from .services import AlertService, build_tracker_response_from_snapshot + +logger = logging.getLogger("farm_alerts") + + +class FarmAlertsBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class AlertTrackerView(FarmAlertsBaseView): + @extend_schema( + tags=["Farm Alerts"], + request=FarmAlertsTrackerRequestSerializer, + examples=[ + OpenApiExample( + "Tracker Request", + value={ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + }, + request_only=True, + ) + ], + responses={200: code_response("FarmAlertsTrackerResponse", data=AlertTrackerAIResponseSerializer())}, + ) + def post(self, request): + request_serializer = FarmAlertsTrackerRequestSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, request_serializer.validated_data["farm_uuid"]) + logger.info( + "tracker endpoint received request farm=%s payload=%s", + farm.farm_uuid, + request.data, + ) + + response_data = build_tracker_response_from_snapshot(farm=farm) + logger.info( + "tracker endpoint returning cached response farm=%s response=%s", + farm.farm_uuid, + response_data, + ) + serializer = AlertTrackerAIResponseSerializer(instance=response_data) + snapshot = getattr(farm, "alert_tracker_snapshot", None) + return Response( + { + "code": 200, + "msg": "success", + "data": serializer.data, + "meta": build_integration_meta( + flow_type="cached_snapshot", + source_type="cached_snapshot", + source_service="backend_farm_alerts_snapshot", + ownership="backend", + live=False, + cached=True, + snapshot_at=getattr(snapshot, "updated_at", None), + notes=["Returns persisted tracker snapshot, not live AI inference."], + ), + }, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/farm_hub/API_REFERENCE_FA.md b/Modules/Backend/farm_hub/API_REFERENCE_FA.md new file mode 100644 index 0000000..a6b77a3 --- /dev/null +++ b/Modules/Backend/farm_hub/API_REFERENCE_FA.md @@ -0,0 +1,918 @@ +# مستندات کامل API های `farm_hub` + +این فایل بر اساس پیاده‌سازی واقعی اپ `farm_hub` در فایل‌های `farm_hub/urls.py`, `farm_hub/views.py`, `farm_hub/serializers.py`, `farm_hub/models.py` و `farm_hub/services.py` تهیه شده است. + +نکته مهم: فایل `farm_hub/apps.py` فقط برای ثبت Django app استفاده می‌شود و خودِ APIها داخل آن تعریف نشده‌اند. APIهای این ماژول در `farm_hub/urls.py` و `farm_hub/views.py` قرار دارند. + +## مشخصات کلی + +- Base path: + +```text +/api/farm-hub/ +``` + +- احراز هویت: + +تمام endpointهای این ماژول نیاز به کاربر لاگین‌شده دارند. + +```http +Authorization: Bearer +``` + +- فرمت کلی پاسخ موفق: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +- فرمت کلی پاسخ خطا: + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +یا در خطاهای validation: + +```json +{ + "field_name": [ + "error message" + ] +} +``` + +## لیست endpointها + +| Method | URL | توضیح | +|---|---|---| +| GET | `/api/farm-hub/` | دریافت لیست مزارع کاربر جاری | +| POST | `/api/farm-hub/` | ساخت مزرعه جدید | +| GET | `/api/farm-hub/farm-types/` | دریافت لیست نوع مزرعه‌ها | +| GET | `/api/farm-hub/farm-types/{farm_type_uuid}/products/` | دریافت محصولات مربوط به یک نوع مزرعه | +| GET | `/api/farm-hub/{farm_uuid}/` | دریافت جزئیات یک مزرعه | +| PATCH | `/api/farm-hub/{farm_uuid}/` | ویرایش مزرعه | +| DELETE | `/api/farm-hub/{farm_uuid}/` | حذف مزرعه | +| POST | `/api/farm-hub/active/` | فعال‌کردن مزرعه | +| POST | `/api/farm-hub/deactive/` | غیرفعال‌کردن مزرعه | + +--- + +## 1) دریافت لیست مزارع + +### Request + +```http +GET /api/farm-hub/ +Authorization: Bearer +``` + +### رفتار + +- فقط مزارع متعلق به کاربر جاری برگردانده می‌شوند. +- برای هر مزرعه، اطلاعات `farm_type`، لیست `products`، لیست `sensors` و `area_uuid` برگردانده می‌شود. + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": { + "moisture": { + "ideal_value": 65 + } + }, + "irrigation_profile": {}, + "growth_profile": {} + } + ], + "sensors": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + }, + "last_updated": "2025-02-18T12:00:00Z" + } + ], + "last_updated": "2025-02-18T12:00:00Z" + } + ] +} +``` + +--- + +## 2) ساخت مزرعه جدید + +### Request + +```http +POST /api/farm-hub/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type_uuid": "11111111-1111-1111-1111-111111111111", + "product_uuids": [ + "22222222-2222-2222-2222-222222222222" + ], + "sensors": [ + { + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + } + } + ], + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39} + ] + }, + "sensor_key": "sensor-7-1", + "sensor_payload": { + "soil_moisture": 45.2, + "soil_temperature": 22.5 + }, + "irrigation_method_id": 3 +} +``` + +برای `farm_boundary` هر دو فرم زیر پشتیبانی می‌شوند: + +```json +{ + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815] + ] + ] + } +} +``` + +### فیلدهای ورودی + +| فیلد | نوع | اجباری | توضیح | +|---|---|---|---| +| `name` | string | بله | نام مزرعه | +| `is_active` | boolean | خیر | وضعیت فعال بودن مزرعه؛ پیش‌فرض مدل `true` است | +| `farm_type_uuid` | uuid | بله | UUID نوع مزرعه | +| `product_uuids` | array[uuid] | بله | لیست UUID محصولات؛ خالی بودن مجاز نیست | +| `sensors` | array | خیر | لیست سنسورهای مزرعه | +| `area_geojson` | object | خیر | محدوده زمین به صورت GeoJSON از نوع `Polygon`؛ اگر `farm_boundary` هم ارسال شود، این فیلد override می‌شود | +| `farm_boundary` | object | خیر | alias برای محدوده مزرعه؛ هم `Polygon` و هم فرم `corners` را می‌پذیرد | +| `sensor_key` | string | خیر | کلید سنسور برای normalize کردن `sensor_payload`؛ پیش فرض `sensor-7-1` | +| `sensor_payload` | object | خیر | داده سنسور که همراه ساخت مزرعه به Farm Data sync می‌شود | +| `irrigation_method_id` | integer/null | خیر | شناسه روش آبیاری که همراه ساخت مزرعه به Farm Data sync می‌شود | + +### فیلدهای هر سنسور در `sensors` + +| فیلد | نوع | اجباری | توضیح | +|---|---|---|---| +| `sensor_catalog_uuid` | uuid | خیر | اگر ارسال شود باید در `SensorCatalog` وجود داشته باشد | +| `physical_device_uuid` | uuid | خیر | شناسه دستگاه فیزیکی؛ اگر داده نشود مدل خودش مقدار تولید می‌کند | +| `name` | string | وابسته به ورودی | نام سنسور؛ اگر `sensor_catalog_uuid` معتبر باشد و `name` نفرستید، از نام catalog استفاده می‌شود، ولی اگر `sensor_catalog_uuid` هم نداشته باشید عملا باید `name` را بفرستید | +| `sensor_type` | string | خیر | نوع سنسور | +| `is_active` | boolean | خیر | وضعیت فعال بودن سنسور | +| `specifications` | object | خیر | مشخصات فنی | +| `power_source` | object | خیر | نوع یا جزئیات منبع تغذیه | + +### اعتبارسنجی‌ها + +- `farm_uuid` اگر از سمت کلاینت ارسال شود نادیده گرفته می‌شود. +- اگر `farm_boundary` به فرم `corners` ارسال شود، به Polygon تبدیل می‌شود. +- `sensor_payload` باید object باشد، وگرنه خطای validation برمی‌گردد. +- `farm_type_uuid` باید معتبر باشد، وگرنه: + +```json +{ + "farm_type_uuid": [ + "Farm type not found." + ] +} +``` + +- `product_uuids` باید همگی وجود داشته باشند: + +```json +{ + "product_uuids": [ + "One or more products were not found." + ] +} +``` + +- همه محصولات باید متعلق به همان `farm_type` باشند: + +```json +{ + "product_uuids": [ + "Products must belong to farm type `زراعی`." + ] +} +``` + +- `sensor_catalog_uuid` اگر ارسال شود باید معتبر باشد: + +```json +{ + "sensors": [ + { + "sensor_catalog_uuid": [ + "Sensor catalog not found." + ] + } + ] +} +``` + +### رفتار داخلی + +- بعد از ساخت مزرعه و zoning، backend درخواست `POST /api/farm-data/` را نیز با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال می‌کند. +- اگر sync با Farm Data شکست بخورد، پاسخ endpoint با کد `502` برمی‌گردد. + +- `area_geojson` باید object معتبر باشد. +- اگر `area_geojson.type == "Feature"` باشد، مقدار `geometry` بررسی می‌شود. +- `geometry.type` فقط باید `Polygon` باشد. +- `coordinates` باید ساختار polygon ring داشته باشد. + +نمونه خطاهای `area_geojson`: + +```json +{ + "area_geojson": [ + "`area_geojson` must be a GeoJSON object." + ] +} +``` + +```json +{ + "area_geojson": [ + "`area_geojson.geometry.type` must be `Polygon`." + ] +} +``` + +### رفتار داخلی مهم + +- اگر `area_geojson` ارسال نشود، سیستم از `get_default_area_feature()` استفاده می‌کند. +- بعد از ساخت مزرعه، فرآیند zoning اجرا می‌شود. +- خروجی zoning به `current_crop_area` وصل می‌شود. +- اگر zoning با موفقیت ساخته شود، در response فیلد `zoning` هم برگردانده می‌شود. + +### Response 201 + +```json +{ + "code": 201, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": {}, + "irrigation_profile": {}, + "growth_profile": {} + } + ], + "sensors": [ + { + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-1" + }, + "power_source": { + "type": "battery" + }, + "last_updated": "2025-02-18T12:00:00Z" + } + ], + "last_updated": "2025-02-18T12:00:00Z", + "zoning": { + "zone_count": 4 + } + } +} +``` + +### Response 500 + +در صورتی که سرویس لازم برای zoning/config به‌درستی تنظیم نشده باشد: + +```json +{ + "code": 500, + "msg": "..." +} +``` + +--- + +## 3) دریافت لیست نوع مزرعه‌ها + +### Request + +```http +GET /api/farm-hub/farm-types/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "درختی", + "description": "", + "metadata": {} + } + ] +} +``` + +### نکته + +- خروجی بر اساس `name` مرتب می‌شود. + +--- + +## 4) دریافت محصولات یک نوع مزرعه + +### Request + +```http +GET /api/farm-hub/farm-types/{farm_type_uuid}/products/ +Authorization: Bearer +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_type_uuid` | uuid | شناسه نوع مزرعه | + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "پاییز", + "harvest_time": "بهار", + "spacing": "", + "fertilizer": "", + "health_profile": { + "moisture": { + "ideal_value": 65 + } + }, + "irrigation_profile": {}, + "growth_profile": {} + } + ] +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm type not found." +} +``` + +### نکته + +- محصولات با ترتیب `name` برگردانده می‌شوند. + +--- + +## 5) دریافت جزئیات یک مزرعه + +### Request + +```http +GET /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_uuid` | uuid | شناسه مزرعه | + +### رفتار + +- فقط اگر مزرعه متعلق به کاربر جاری باشد برگردانده می‌شود. +- اگر UUID وجود داشته باشد ولی متعلق به کاربر دیگری باشد، عملا مثل not found رفتار می‌شود. + +### Response 200 + +ساختار `data` دقیقا مثل آیتم‌های خروجی لیست مزرعه‌ها است. + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +--- + +## 6) ویرایش مزرعه + +### Request + +```http +PATCH /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +Content-Type: application/json +``` + +### Path Params + +| پارامتر | نوع | توضیح | +|---|---|---| +| `farm_uuid` | uuid | شناسه مزرعه | + +### Body + +این endpoint از `partial update` استفاده می‌کند؛ یعنی می‌توانید فقط بخشی از فیلدها را بفرستید. + +نمونه: + +```json +{ + "name": "مزرعه اصلاح شده", + "is_active": false, + "farm_type_uuid": "11111111-1111-1111-1111-111111111111", + "product_uuids": [ + "22222222-2222-2222-2222-222222222222" + ], + "sensors": [ + { + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station Updated", + "sensor_type": "weather_station", + "is_active": true, + "specifications": { + "model": "FH-2" + }, + "power_source": { + "type": "solar" + } + } + ], + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39} + ] + }, + "sensor_payload": { + "soil_moisture": 45.2 + }, + "irrigation_method_id": 3 +} +``` + +### رفتار update + +- `name` و `is_active` در صورت ارسال تغییر می‌کنند. +- اگر `farm_type_uuid` ارسال شود، نوع مزرعه به‌روزرسانی می‌شود. +- اگر `product_uuids` ارسال شود، همه محصولات مزرعه با لیست جدید جایگزین می‌شوند. +- اگر `sensors` ارسال شود، همه سنسورهای قبلی حذف و سپس سنسورهای جدید از نو ساخته می‌شوند. +- اگر `area_geojson` یا `farm_boundary` ارسال شود، zoning مجدد انجام می‌شود و `current_crop_area` به‌روزرسانی می‌شود. +- در هر update نیز درخواست sync به `POST /api/farm-data/` با `farm_uuid`، `farm_boundary`، `plant_ids` و در صورت وجود `sensor_payload`/`irrigation_method_id` ارسال می‌شود. + +### اعتبارسنجی + +همان قوانین create اینجا هم برقرار است، با این تفاوت: + +- در update، اگر `farm_type_uuid` ارسال نشود، از `farm_type` فعلی استفاده می‌شود. +- در update، اگر `product_uuids` ارسال نشود، محصولات فعلی حفظ می‌شوند. +- در update، اگر `sensors` ارسال نشود، سنسورهای فعلی حفظ می‌شوند. +- در update نیز `sensor_payload` باید object باشد. + +### Response 200 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه اصلاح شده", + "is_active": false, + "farm_type": { + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} + }, + "products": [], + "sensors": [], + "last_updated": "2025-02-18T13:00:00Z" + } +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### Response 502 + +```json +{ + "code": 502, + "msg": "Farm data API returned status 400: ..." +} +``` + +--- + +## 7) حذف مزرعه + +### Request + +```http +DELETE /api/farm-hub/{farm_uuid}/ +Authorization: Bearer +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### نکته + +- فقط مزرعه متعلق به کاربر جاری حذف می‌شود. + +--- + +## 8) فعال‌کردن مزرعه + +### Request + +```http +POST /api/farm-hub/active/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +### خطای validation + +اگر `farm_uuid` ارسال نشود یا فرمت آن درست نباشد، خطای serializer برگردانده می‌شود. نمونه: + +```json +{ + "farm_uuid": [ + "This field is required." + ] +} +``` + +--- + +## 9) غیرفعال‌کردن مزرعه + +### Request + +```http +POST /api/farm-hub/deactive/ +Authorization: Bearer +Content-Type: application/json +``` + +### Body + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +### Response 200 + +```json +{ + "code": 200, + "msg": "success" +} +``` + +### Response 404 + +```json +{ + "code": 404, + "msg": "Farm not found." +} +``` + +--- + +## ساختار آبجکت‌ها + +## آبجکت `FarmType` + +```json +{ + "uuid": "11111111-1111-1111-1111-111111111111", + "name": "زراعی", + "description": "", + "metadata": {} +} +``` + +## آبجکت `Product` + +```json +{ + "uuid": "22222222-2222-2222-2222-222222222222", + "name": "گندم", + "description": "", + "metadata": {}, + "light": "", + "watering": "", + "soil": "", + "temperature": "", + "planting_season": "", + "harvest_time": "", + "spacing": "", + "fertilizer": "", + "health_profile": {}, + "irrigation_profile": {}, + "growth_profile": {} +} +``` + +### توضیح فیلدهای پروفایل محصول + +- `health_profile`: برای KPIها و سلامت محصول +- `irrigation_profile`: برای محاسبات آبیاری و ETc +- `growth_profile`: برای مدل رشد مانند GDD + +نمونه ساختار `health_profile`: + +```json +{ + "moisture": { + "ideal_value": 65, + "min_range": 45, + "max_range": 75, + "weight": 0.4 + } +} +``` + +نمونه ساختار `irrigation_profile`: + +```json +{ + "kc_initial": 0.6, + "kc_mid": 1.15, + "kc_end": 0.8, + "growth_stage_duration": { + "initial": 20, + "mid": 30, + "late": 25 + } +} +``` + +نمونه ساختار `growth_profile`: + +```json +{ + "base_temperature": 10, + "required_gdd_for_maturity": 1200, + "stage_thresholds": { + "flowering": 500, + "fruiting": 850 + }, + "current_cumulative_gdd": 320 +} +``` + +## آبجکت `FarmSensor` + +```json +{ + "uuid": "33333333-3333-3333-3333-333333333333", + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "physical_device_uuid": "55555555-5555-5555-5555-555555555555", + "name": "Station 1", + "sensor_type": "weather_station", + "is_active": true, + "specifications": {}, + "power_source": {}, + "last_updated": "2025-02-18T12:00:00Z" +} +``` + +## آبجکت `FarmHub` + +```json +{ + "farm_uuid": "550e8400-e29b-41d4-a716-446655440000", + "area_uuid": "0c7dfd7f-94bf-46f3-b2f9-30a89f0a1111", + "name": "مزرعه شماره 1", + "is_active": true, + "farm_type": {}, + "products": [], + "sensors": [], + "last_updated": "2025-02-18T12:00:00Z" +} +``` + +--- + +## نکات مهم برای فرانت‌اند + +- برای ساخت مزرعه، ابتدا `GET /api/farm-hub/farm-types/` را صدا بزنید. +- سپس برای نوع انتخاب‌شده، `GET /api/farm-hub/farm-types/{farm_type_uuid}/products/` را بگیرید. +- برای ساخت یا ویرایش مزرعه، `product_uuids` باید با `farm_type_uuid` هم‌خوانی داشته باشند. +- اگر در update فیلد `sensors` را بفرستید، لیست قبلی کامل جایگزین می‌شود. +- اگر در create، `area_geojson` نفرستید، سیستم خودش یک محدوده پیش‌فرض می‌سازد. +- endpointهای detail/update/delete/active/deactive فقط روی مزرعه‌های خود کاربر عمل می‌کنند. + +--- + +## منبع پیاده‌سازی + +- رجیستر اپ: `farm_hub/apps.py` +- تعریف routeها: `farm_hub/urls.py` +- منطق APIها: `farm_hub/views.py` +- serializerها و validation: `farm_hub/serializers.py` +- مدل‌ها: `farm_hub/models.py` +- منطق ساخت zoning: `farm_hub/services.py` +- نمونه requestها: `farm_hub/postman/farm_hub.json` diff --git a/Modules/Backend/farm_hub/__init__.py b/Modules/Backend/farm_hub/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/farm_hub/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/farm_hub/apps.py b/Modules/Backend/farm_hub/apps.py new file mode 100644 index 0000000..2b14420 --- /dev/null +++ b/Modules/Backend/farm_hub/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FarmHubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farm_hub" diff --git a/Modules/Backend/farm_hub/catalog.py b/Modules/Backend/farm_hub/catalog.py new file mode 100644 index 0000000..bddee2f --- /dev/null +++ b/Modules/Backend/farm_hub/catalog.py @@ -0,0 +1,23 @@ +CATALOG_SEED_DATA = { + "زراعی": [ + {"name": "گندم", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی", "icon": "wheat", "growth_stage": "vegetative", "growth_stages": ["initial", "vegetative", "flowering", "maturity"]}, + {"name": "ذرت", "planting_season": "بهار", "harvest_time": "تابستان", "soil": "لومی شنی", "icon": "corn", "growth_stage": "vegetative", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "جو", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی", "icon": "leaf", "growth_stage": "vegetative", "growth_stages": ["initial", "vegetative", "flowering", "maturity"]}, + {"name": "کلزا", "planting_season": "پاییز", "harvest_time": "بهار", "soil": "لومی رسی", "icon": "leaf", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "maturity"]}, + {"name": "پنبه", "planting_season": "بهار", "harvest_time": "پاییز", "soil": "لومی", "icon": "leaf", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + ], + "درختی": [ + {"name": "سیب", "planting_season": "زمستان", "harvest_time": "پاییز", "soil": "لومی", "icon": "apple", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "پسته", "planting_season": "زمستان", "harvest_time": "اواخر تابستان", "soil": "شنی لومی", "icon": "leaf", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "انگور", "planting_season": "اواخر زمستان", "harvest_time": "تابستان", "soil": "لومی", "icon": "grape", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "انار", "planting_season": "اواخر زمستان", "harvest_time": "پاییز", "soil": "لومی شنی", "icon": "leaf", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + ], + "غرقابی": [ + {"name": "برنج", "planting_season": "بهار", "harvest_time": "اواخر تابستان", "soil": "رسی", "icon": "leaf", "growth_stage": "vegetative", "growth_stages": ["initial", "vegetative", "flowering", "maturity"]}, + ], + "گلخانه ای": [ + {"name": "گوجه‌فرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت", "icon": "tomato", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "خیار", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "پرلیت", "icon": "leaf", "growth_stage": "fruiting", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + {"name": "فلفل دلمه‌ای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک", "icon": "pepper", "growth_stage": "flowering", "growth_stages": ["initial", "vegetative", "flowering", "fruiting", "maturity"]}, + ], +} diff --git a/Modules/Backend/farm_hub/management/__init__.py b/Modules/Backend/farm_hub/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/farm_hub/management/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/farm_hub/management/commands/__init__.py b/Modules/Backend/farm_hub/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/farm_hub/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/farm_hub/management/commands/seed_admin_farm.py b/Modules/Backend/farm_hub/management/commands/seed_admin_farm.py new file mode 100644 index 0000000..7a407ff --- /dev/null +++ b/Modules/Backend/farm_hub/management/commands/seed_admin_farm.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand, CommandError + +from farm_hub.seeds import seed_admin_farm + + +class Command(BaseCommand): + help = "Create or update the default farm hub for the admin user." + + def handle(self, *args, **options): + try: + farm, created = seed_admin_farm() + except ValueError as exc: + raise CommandError(str(exc)) from exc + + action = "created" if created else "updated" + self.stdout.write( + self.style.SUCCESS( + f"Admin farm {action}: farm_uuid={farm.farm_uuid}, name={farm.name}, owner={farm.owner.username}" + ) + ) diff --git a/Modules/Backend/farm_hub/management/commands/seed_farm_catalog.py b/Modules/Backend/farm_hub/management/commands/seed_farm_catalog.py new file mode 100644 index 0000000..30cca88 --- /dev/null +++ b/Modules/Backend/farm_hub/management/commands/seed_farm_catalog.py @@ -0,0 +1,29 @@ +from django.core.management.base import BaseCommand + +from farm_hub.catalog import CATALOG_SEED_DATA +from farm_hub.models import FarmType, Product + + +class Command(BaseCommand): + help = "Seed farm types and products catalog data." + + def handle(self, *args, **options): + farm_type_count = 0 + product_count = 0 + + for farm_type_name, products in CATALOG_SEED_DATA.items(): + farm_type, created = FarmType.objects.get_or_create(name=farm_type_name) + farm_type_count += int(created) + for product_data in products: + _, product_created = Product.objects.update_or_create( + farm_type=farm_type, + name=product_data["name"], + defaults={key: value for key, value in product_data.items() if key != "name"}, + ) + product_count += int(product_created) + + self.stdout.write( + self.style.SUCCESS( + f"Farm catalog seeded successfully. Created farm types: {farm_type_count}, products: {product_count}." + ) + ) diff --git a/Modules/Backend/farm_hub/migrations/0001_initial.py b/Modules/Backend/farm_hub/migrations/0001_initial.py new file mode 100644 index 0000000..8cd03eb --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0001_initial.py @@ -0,0 +1,125 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FarmType", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(db_index=True, max_length=255, unique=True)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "farm_types", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="FarmHub", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("farm_uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("customization", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="farms", + to="farm_hub.farmtype", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="farm_hubs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "farm_hubs", + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="Product", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(db_index=True, max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="products", + to="farm_hub.farmtype", + ), + ), + ], + options={ + "db_table": "products", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="FarmSensor", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("name", models.CharField(max_length=255)), + ("sensor_type", models.CharField(blank=True, default="", max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("specifications", models.JSONField(blank=True, default=dict)), + ("power_source", models.JSONField(blank=True, default=dict)), + ("customization", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sensors", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_sensors", + "ordering": ["-created_at"], + }, + ), + migrations.AddField( + model_name="farmhub", + name="products", + field=models.ManyToManyField(blank=True, related_name="farms", to="farm_hub.product"), + ), + migrations.AddConstraint( + model_name="product", + constraint=models.UniqueConstraint(fields=("farm_type", "name"), name="unique_product_per_farm_type"), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0002_seed_default_catalog.py b/Modules/Backend/farm_hub/migrations/0002_seed_default_catalog.py new file mode 100644 index 0000000..99c312b --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0002_seed_default_catalog.py @@ -0,0 +1,34 @@ +from django.db import migrations + + +FARM_TYPES = { + "زراعی": ["گندم", "ذرت", "جو", "کلزا", "پنبه"], + "درختی": ["سیب", "پسته", "انگور", "انار"], + "غرقابی": ["برنج"], + "گلخانه ای": ["گوجه فرنگی", "خیار", "فلفل دلمه ای"], +} + + +def seed_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + Product = apps.get_model("farm_hub", "Product") + + for farm_type_name, products in FARM_TYPES.items(): + farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name) + for product_name in products: + Product.objects.get_or_create(farm_type=farm_type, name=product_name) + + +def unseed_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + FarmType.objects.filter(name__in=FARM_TYPES.keys()).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_catalog, unseed_catalog), + ] diff --git a/Modules/Backend/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py b/Modules/Backend/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py new file mode 100644 index 0000000..08ad4c3 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0003_farmsensor_catalog_and_physical_device.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.12 on 2026-03-20 00:30 + +import uuid +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0001_initial"), + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.AddField( + model_name="farmsensor", + name="physical_device_uuid", + field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True), + ), + migrations.AddField( + model_name="farmsensor", + name="sensor_catalog", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farm_sensors", + to="device_hub.sensorcatalog", + ), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py b/Modules/Backend/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py new file mode 100644 index 0000000..950747e --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0004_remove_customization_add_current_crop_area.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.12 on 2026-03-20 01:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("crop_zoning", "0004_croparea_farm"), + ("farm_hub", "0003_farmsensor_catalog_and_physical_device"), + ] + + operations = [ + migrations.RemoveField( + model_name="farmhub", + name="customization", + ), + migrations.RemoveField( + model_name="farmsensor", + name="customization", + ), + migrations.AddField( + model_name="farmhub", + name="current_crop_area", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="current_for_farms", + to="crop_zoning.croparea", + ), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0005_product_profiles_and_plant_migration.py b/Modules/Backend/farm_hub/migrations/0005_product_profiles_and_plant_migration.py new file mode 100644 index 0000000..9fa00c1 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0005_product_profiles_and_plant_migration.py @@ -0,0 +1,163 @@ +import json + +from django.db import migrations, models + + +DEFAULT_FARM_TYPE_NAME = "زراعی" + + +def _table_exists(schema_editor, table_name): + with schema_editor.connection.cursor() as cursor: + existing_tables = set(schema_editor.connection.introspection.table_names(cursor)) + return table_name in existing_tables + + +def _deserialize_json(value): + if value in (None, "", b""): + return {} + if isinstance(value, (dict, list)): + return value + if isinstance(value, bytes): + value = value.decode("utf-8") + try: + return json.loads(value) + except (TypeError, ValueError): + return {} + + +def migrate_plant_rows_to_products(apps, schema_editor): + if not _table_exists(schema_editor, "plant_plant"): + return + + FarmType = apps.get_model("farm_hub", "FarmType") + Product = apps.get_model("farm_hub", "Product") + + farm_type, _ = FarmType.objects.get_or_create(name=DEFAULT_FARM_TYPE_NAME) + + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + SELECT + name, + light, + watering, + soil, + temperature, + planting_season, + harvest_time, + spacing, + fertilizer, + health_profile, + irrigation_profile, + growth_profile, + created_at, + updated_at + FROM plant_plant + """ + ) + columns = [column[0] for column in cursor.description] + rows = [dict(zip(columns, row)) for row in cursor.fetchall()] + + for row in rows: + Product.objects.update_or_create( + farm_type=farm_type, + name=row["name"], + defaults={ + "light": row["light"] or "", + "watering": row["watering"] or "", + "soil": row["soil"] or "", + "temperature": row["temperature"] or "", + "planting_season": row["planting_season"] or "", + "harvest_time": row["harvest_time"] or "", + "spacing": row["spacing"] or "", + "fertilizer": row["fertilizer"] or "", + "health_profile": _deserialize_json(row["health_profile"]), + "irrigation_profile": _deserialize_json(row["irrigation_profile"]), + "growth_profile": _deserialize_json(row["growth_profile"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + }, + ) + + +def drop_legacy_plant_table(apps, schema_editor): + if _table_exists(schema_editor, "plant_plant"): + schema_editor.execute("DROP TABLE plant_plant") + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0004_remove_customization_add_current_crop_area"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="fertilizer", + field=models.CharField(blank=True, default="", help_text="کود مناسب", max_length=255), + ), + migrations.AddField( + model_name="product", + name="growth_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text='پروفایل رشد محصول برای مدل GDD. {"base_temperature": 10, "required_gdd_for_maturity": 1200, "stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}', + ), + ), + migrations.AddField( + model_name="product", + name="harvest_time", + field=models.CharField(blank=True, default="", help_text="زمان برداشت", max_length=255), + ), + migrations.AddField( + model_name="product", + name="health_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text='پروفایل سلامت محصول برای KPIها. ساختار نمونه: {"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}', + ), + ), + migrations.AddField( + model_name="product", + name="irrigation_profile", + field=models.JSONField( + blank=True, + default=dict, + help_text='پروفایل آبیاری محصول برای محاسبات ETc. {"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, "growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}', + ), + ), + migrations.AddField( + model_name="product", + name="light", + field=models.CharField(blank=True, default="", help_text="نور مورد نیاز", max_length=255), + ), + migrations.AddField( + model_name="product", + name="planting_season", + field=models.CharField(blank=True, default="", help_text="فصل کاشت", max_length=255), + ), + migrations.AddField( + model_name="product", + name="soil", + field=models.CharField(blank=True, default="", help_text="خاک مناسب", max_length=255), + ), + migrations.AddField( + model_name="product", + name="spacing", + field=models.CharField(blank=True, default="", help_text="فاصله کاشت", max_length=255), + ), + migrations.AddField( + model_name="product", + name="temperature", + field=models.CharField(blank=True, default="", help_text="دمای مناسب", max_length=255), + ), + migrations.AddField( + model_name="product", + name="watering", + field=models.CharField(blank=True, default="", help_text="آبیاری", max_length=255), + ), + migrations.RunPython(migrate_plant_rows_to_products, migrations.RunPython.noop), + migrations.RunPython(drop_legacy_plant_table, migrations.RunPython.noop), + ] diff --git a/Modules/Backend/farm_hub/migrations/0006_seed_expanded_product_catalog.py b/Modules/Backend/farm_hub/migrations/0006_seed_expanded_product_catalog.py new file mode 100644 index 0000000..5cfcd50 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0006_seed_expanded_product_catalog.py @@ -0,0 +1,55 @@ +from django.db import migrations + + +CATALOG_SEED_DATA = { + "زراعی": [ + {"name": "گندم", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"}, + {"name": "ذرت", "planting_season": "بهار", "harvest_time": "تابستان", "soil": "لومی شنی"}, + {"name": "جو", "planting_season": "پاییز", "harvest_time": "اواخر بهار", "soil": "لومی"}, + {"name": "کلزا", "planting_season": "پاییز", "harvest_time": "بهار", "soil": "لومی رسی"}, + {"name": "پنبه", "planting_season": "بهار", "harvest_time": "پاییز", "soil": "لومی"}, + ], + "درختی": [ + {"name": "سیب", "planting_season": "زمستان", "harvest_time": "پاییز", "soil": "لومی"}, + {"name": "پسته", "planting_season": "زمستان", "harvest_time": "اواخر تابستان", "soil": "شنی لومی"}, + {"name": "انگور", "planting_season": "اواخر زمستان", "harvest_time": "تابستان", "soil": "لومی"}, + {"name": "انار", "planting_season": "اواخر زمستان", "harvest_time": "پاییز", "soil": "لومی شنی"}, + ], + "غرقابی": [ + {"name": "برنج", "planting_season": "بهار", "harvest_time": "اواخر تابستان", "soil": "رسی"}, + ], + "گلخانه ای": [ + {"name": "گوجه فرنگی", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "کوکوپیت"}, + {"name": "خیار", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "پرلیت"}, + {"name": "فلفل دلمه ای", "planting_season": "چهار فصل", "harvest_time": "چند مرحله ای", "soil": "بستر هیدروپونیک"}, + ], +} + + +def seed_expanded_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + Product = apps.get_model("farm_hub", "Product") + + for farm_type_name, products in CATALOG_SEED_DATA.items(): + farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name) + for product_data in products: + Product.objects.update_or_create( + farm_type=farm_type, + name=product_data["name"], + defaults={key: value for key, value in product_data.items() if key != "name"}, + ) + + +def unseed_expanded_catalog(apps, schema_editor): + FarmType = apps.get_model("farm_hub", "FarmType") + FarmType.objects.filter(name__in=CATALOG_SEED_DATA.keys()).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0005_product_profiles_and_plant_migration"), + ] + + operations = [ + migrations.RunPython(seed_expanded_catalog, unseed_expanded_catalog), + ] diff --git a/Modules/Backend/farm_hub/migrations/0007_farmhub_subscription_plan.py b/Modules/Backend/farm_hub/migrations/0007_farmhub_subscription_plan.py new file mode 100644 index 0000000..763ef49 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0007_farmhub_subscription_plan.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.12 on 2026-04-03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0006_seed_expanded_product_catalog"), + ("access_control", "0002_link_subscription_plan_to_farm"), + ] + + operations = [ + migrations.AddField( + model_name="farmhub", + name="subscription_plan", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="farms", + to="access_control.subscriptionplan", + ), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0008_product_plant_selector_fields.py b/Modules/Backend/farm_hub/migrations/0008_product_plant_selector_fields.py new file mode 100644 index 0000000..2b687d6 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0008_product_plant_selector_fields.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0007_farmhub_subscription_plan"), + ] + + operations = [ + migrations.AddField( + model_name="product", + name="growth_stage", + field=models.CharField(blank=True, default="", help_text="مرحله رشد فعلی", max_length=255), + ), + migrations.AddField( + model_name="product", + name="growth_stages", + field=models.JSONField(blank=True, default=list, help_text="فهرست مراحل رشد محصول"), + ), + migrations.AddField( + model_name="product", + name="icon", + field=models.CharField(blank=True, default="", help_text="آیکون محصول برای فرانت", max_length=100), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py b/Modules/Backend/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py new file mode 100644 index 0000000..14bbba9 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0009_farmhub_irrigation_method_fields.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("farm_hub", "0008_product_plant_selector_fields"), + ] + + operations = [ + migrations.AddField( + model_name="farmhub", + name="irrigation_method_id", + field=models.IntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmhub", + name="irrigation_method_name", + field=models.CharField(blank=True, default="", max_length=255), + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py b/Modules/Backend/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py new file mode 100644 index 0000000..7e206d7 --- /dev/null +++ b/Modules/Backend/farm_hub/migrations/0010_move_farmsensor_to_device_hub.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("device_hub", "0001_initial"), + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.DeleteModel(name="FarmSensor"), + ], + ), + ] diff --git a/Modules/Backend/farm_hub/migrations/__init__.py b/Modules/Backend/farm_hub/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farm_hub/models.py b/Modules/Backend/farm_hub/models.py new file mode 100644 index 0000000..c3571b7 --- /dev/null +++ b/Modules/Backend/farm_hub/models.py @@ -0,0 +1,124 @@ +import uuid as uuid_lib + +from django.conf import settings +from django.db import models + + +class FarmType(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + name = models.CharField(max_length=255, unique=True, db_index=True) + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_types" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Product(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm_type = models.ForeignKey( + FarmType, + on_delete=models.CASCADE, + related_name="products", + ) + name = models.CharField(max_length=255, db_index=True) + description = models.TextField(blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + light = models.CharField(max_length=255, blank=True, default="", help_text="نور مورد نیاز") + watering = models.CharField(max_length=255, blank=True, default="", help_text="آبیاری") + soil = models.CharField(max_length=255, blank=True, default="", help_text="خاک مناسب") + temperature = models.CharField(max_length=255, blank=True, default="", help_text="دمای مناسب") + growth_stage = models.CharField(max_length=255, blank=True, default="", help_text="مرحله رشد فعلی") + growth_stages = models.JSONField(blank=True, default=list, help_text="فهرست مراحل رشد محصول") + icon = models.CharField(max_length=100, blank=True, default="", help_text="آیکون محصول برای فرانت") + planting_season = models.CharField(max_length=255, blank=True, default="", help_text="فصل کاشت") + harvest_time = models.CharField(max_length=255, blank=True, default="", help_text="زمان برداشت") + spacing = models.CharField(max_length=255, blank=True, default="", help_text="فاصله کاشت") + fertilizer = models.CharField(max_length=255, blank=True, default="", help_text="کود مناسب") + health_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل سلامت محصول برای KPIها. ساختار نمونه: " + '{"moisture": {"ideal_value": 65, "min_range": 45, "max_range": 75, "weight": 0.4}}' + ), + ) + irrigation_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل آبیاری محصول برای محاسبات ETc. " + '{"kc_initial": 0.6, "kc_mid": 1.15, "kc_end": 0.8, ' + '"growth_stage_duration": {"initial": 20, "mid": 30, "late": 25}}' + ), + ) + growth_profile = models.JSONField( + blank=True, + default=dict, + help_text=( + "پروفایل رشد محصول برای مدل GDD. " + '{"base_temperature": 10, "required_gdd_for_maturity": 1200, ' + '"stage_thresholds": {"flowering": 500, "fruiting": 850}, "current_cumulative_gdd": 320}' + ), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "products" + ordering = ["name"] + constraints = [ + models.UniqueConstraint(fields=["farm_type", "name"], name="unique_product_per_farm_type"), + ] + + def __str__(self): + return self.name + + +class FarmHub(models.Model): + farm_uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="farm_hubs", + ) + farm_type = models.ForeignKey( + FarmType, + on_delete=models.PROTECT, + related_name="farms", + ) + subscription_plan = models.ForeignKey( + "access_control.SubscriptionPlan", + on_delete=models.PROTECT, + related_name="farms", + null=True, + blank=True, + ) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + irrigation_method_id = models.IntegerField(null=True, blank=True) + irrigation_method_name = models.CharField(max_length=255, blank=True, default="") + current_crop_area = models.ForeignKey( + "crop_zoning.CropArea", + on_delete=models.SET_NULL, + related_name="current_for_farms", + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + products = models.ManyToManyField(Product, related_name="farms", blank=True) + + class Meta: + db_table = "farm_hubs" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.name} ({self.farm_uuid})" + diff --git a/Modules/Backend/farm_hub/postman/farm_hub.json b/Modules/Backend/farm_hub/postman/farm_hub.json new file mode 100644 index 0000000..8cafcab --- /dev/null +++ b/Modules/Backend/farm_hub/postman/farm_hub.json @@ -0,0 +1,117 @@ +{ + "info": { + "name": "Farm Hub", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Farm Hub API. GET list, GET by uuid, POST add, PATCH update, DELETE delete, POST active/deactive. Authenticated user required." + }, + "item": [ + { + "name": "List farms", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/", + "description": "Get farms for current user." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": [\n {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ]\n}" + } + ] + }, + { + "name": "Get farm details", + "request": { + "method": "GET", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Get one farm by farm uuid." + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\",\n \"name\": \"مزرعه نمونه\",\n \"is_active\": true,\n \"customization\": {\"report_interval_sec\": 300},\n \"farm_type\": {\"uuid\": \"11111111-1111-1111-1111-111111111111\", \"name\": \"زراعی\", \"description\": \"\", \"metadata\": {}},\n \"products\": [{\"uuid\": \"22222222-2222-2222-2222-222222222222\", \"name\": \"گندم\", \"description\": \"\", \"metadata\": {}}],\n \"sensors\": [\n {\n \"uuid\": \"33333333-3333-3333-3333-333333333333\",\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300},\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n ],\n \"last_updated\": \"2025-02-18T12:00:00Z\"\n }\n}" + } + ] + }, + { + "name": "Create farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"name\": \"مزرعه شماره 1\",\n \"farm_type_uuid\": \"11111111-1111-1111-1111-111111111111\",\n \"product_uuids\": [\"22222222-2222-2222-2222-222222222222\"],\n \"customization\": {\"report_interval_sec\": 300},\n \"sensors\": [\n {\n \"name\": \"Station 1\",\n \"sensor_type\": \"weather_station\",\n \"is_active\": true,\n \"specifications\": {\"model\": \"FH-1\"},\n \"power_source\": {\"type\": \"battery\"},\n \"customization\": {\"report_interval_sec\": 300}\n }\n ]\n}"}, + "url": "{{baseUrl}}/api/farm-hub/", + "description": "Create a farm with its sensors." + } + }, + { + "name": "Update farm", + "request": { + "method": "PATCH", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{}"}, + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Update farm by farm uuid." + } + }, + { + "name": "Delete farm", + "request": { + "method": "DELETE", + "header": [ + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "url": "{{baseUrl}}/api/farm-hub/{{farmUuid}}/", + "description": "Delete farm by farm uuid." + } + }, + { + "name": "Activate farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"}, + "url": "{{baseUrl}}/api/farm-hub/active/", + "description": "Activate one farm." + } + }, + { + "name": "Deactivate farm", + "request": { + "method": "POST", + "header": [ + {"key": "Content-Type", "value": "application/json"}, + {"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"} + ], + "body": {"mode": "raw", "raw": "{\n \"farm_uuid\": \"550e8400-e29b-41d4-a716-446655440000\"\n}"}, + "url": "{{baseUrl}}/api/farm-hub/deactive/", + "description": "Deactivate one farm." + } + } + ], + "variable": [ + {"key": "baseUrl", "value": "http://localhost:8000"}, + {"key": "token", "value": ""}, + {"key": "farmUuid", "value": "550e8400-e29b-41d4-a716-446655440000"} + ] +} diff --git a/Modules/Backend/farm_hub/seeds.py b/Modules/Backend/farm_hub/seeds.py new file mode 100644 index 0000000..bb64768 --- /dev/null +++ b/Modules/Backend/farm_hub/seeds.py @@ -0,0 +1,103 @@ +import uuid + +from django.db import transaction + +from account.seeds import seed_admin_user +from device_hub.catalog_seed import seed_sensor_catalog +from device_hub.models import DeviceCatalog + +from .catalog import CATALOG_SEED_DATA +from .models import FarmHub, FarmType, Product +from .services import dispatch_farm_zoning + + +ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111") +ADMIN_FARM_DATA = { + "name": "Admin Smart Farm", + "is_active": True, + "irrigation_method_id": 1, + "irrigation_method_name": "آبیاری قطره ای", + "sensors": [ + { + "sensor_catalog_code": "sensor_7_soil_moisture_sensor_v1_2", + "physical_device_uuid": uuid.UUID("22222222-2222-2222-2222-222222222222"), + "name": "Soil Probe 1", + "sensor_type": "soil_probe", + "is_active": True, + "specifications": { + "capabilities": ["soil_moisture", "analog_output", "digital_output"], + }, + "power_source": {"type": "solar"}, + }, + ], +} + +ADMIN_FARM_AREA_GEOJSON = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815], + ] + ], + }, +} + + +def _get_default_catalog(): + default_farm_type_name = "گلخانه ای" + created_products = [] + + for farm_type_name, products in CATALOG_SEED_DATA.items(): + farm_type, _ = FarmType.objects.get_or_create(name=farm_type_name) + for product_data in products: + product, _ = Product.objects.update_or_create( + farm_type=farm_type, + name=product_data["name"], + defaults={key: value for key, value in product_data.items() if key != "name"}, + ) + if farm_type_name == default_farm_type_name: + created_products.append(product) + + return FarmType.objects.get(name=default_farm_type_name), created_products[:2] + + +def _get_sensor_catalog_by_code(code): + return DeviceCatalog.objects.filter(code=code).first() + + +@transaction.atomic +def seed_admin_farm(): + seed_sensor_catalog() + owner, _ = seed_admin_user() + farm_type, products = _get_default_catalog() + farm, created = FarmHub.objects.update_or_create( + farm_uuid=ADMIN_FARM_UUID, + defaults={ + "owner": owner, + "farm_type": farm_type, + "name": ADMIN_FARM_DATA["name"], + "is_active": ADMIN_FARM_DATA["is_active"], + "irrigation_method_id": ADMIN_FARM_DATA["irrigation_method_id"], + "irrigation_method_name": ADMIN_FARM_DATA["irrigation_method_name"], + }, + ) + farm.products.set(products) + farm.sensors.all().delete() + sensors = [] + for sensor_data in ADMIN_FARM_DATA["sensors"]: + sensor_data = sensor_data.copy() + sensor_catalog_code = sensor_data.pop("sensor_catalog_code", None) + sensor_data["sensor_catalog"] = _get_sensor_catalog_by_code(sensor_catalog_code) if sensor_catalog_code else None + sensors.append(farm.sensors.model(farm=farm, **sensor_data)) + farm.sensors.bulk_create(sensors) + if created: + crop_area, _zoning_payload = dispatch_farm_zoning(ADMIN_FARM_AREA_GEOJSON, farm) + farm.current_crop_area = crop_area + farm.save(update_fields=["current_crop_area", "updated_at"]) + return farm, created diff --git a/Modules/Backend/farm_hub/serializers.py b/Modules/Backend/farm_hub/serializers.py new file mode 100644 index 0000000..1361e6b --- /dev/null +++ b/Modules/Backend/farm_hub/serializers.py @@ -0,0 +1,325 @@ +from rest_framework import serializers +from access_control.models import SubscriptionPlan +from access_control.serializers import SubscriptionPlanSerializer +from access_control.catalog import GOLD_PLAN_CODE +from access_control.services import get_effective_subscription_plan +from device_hub.models import DeviceCatalog, FarmDevice + +from .models import FarmHub, FarmType, Product +from .services import normalize_farm_boundary_input + + +class FarmTypeSerializer(serializers.ModelSerializer): + class Meta: + model = FarmType + fields = ["uuid", "name", "description", "metadata"] + + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = [ + "uuid", + "name", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + ] + + +class FarmDeviceSerializer(serializers.ModelSerializer): + last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + sensor_catalog_uuid = serializers.UUIDField(source="sensor_catalog.uuid", read_only=True) + device_catalog_uuids = serializers.SerializerMethodField() + device_catalog_codes = serializers.SerializerMethodField() + + class Meta: + model = FarmDevice + fields = [ + "uuid", + "sensor_catalog_uuid", + "device_catalog_uuids", + "device_catalog_codes", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + "last_updated", + ] + read_only_fields = ["uuid", "last_updated"] + + def get_device_catalog_uuids(self, obj): + return [str(catalog.uuid) for catalog in obj.get_device_catalogs()] + + def get_device_catalog_codes(self, obj): + return [catalog.code for catalog in obj.get_device_catalogs()] + + +class FarmHubSerializer(serializers.ModelSerializer): + last_updated = serializers.DateTimeField(source="updated_at", read_only=True) + farm_type = FarmTypeSerializer(read_only=True) + subscription_plan = serializers.SerializerMethodField() + products = ProductSerializer(many=True, read_only=True) + sensors = FarmDeviceSerializer(many=True, read_only=True) + area_uuid = serializers.UUIDField(source="current_crop_area.uuid", read_only=True) + + class Meta: + model = FarmHub + fields = [ + "farm_uuid", + "area_uuid", + "name", + "is_active", + "irrigation_method_id", + "irrigation_method_name", + "farm_type", + "subscription_plan", + "products", + "sensors", + "last_updated", + ] + read_only_fields = ["farm_uuid", "last_updated"] + + def get_subscription_plan(self, obj): + subscription_plan = get_effective_subscription_plan(obj) + if subscription_plan is None: + return None + return SubscriptionPlanSerializer(subscription_plan, context=self.context).data + + +class FarmDeviceWriteSerializer(serializers.ModelSerializer): + sensor_catalog_uuid = serializers.UUIDField(write_only=True, required=False) + device_catalog_uuids = serializers.ListField( + child=serializers.UUIDField(), + write_only=True, + required=False, + allow_empty=False, + ) + + class Meta: + model = FarmDevice + fields = [ + "sensor_catalog_uuid", + "device_catalog_uuids", + "physical_device_uuid", + "name", + "sensor_type", + "is_active", + "specifications", + "power_source", + ] + + def validate(self, attrs): + sensor_catalog_uuid = attrs.pop("sensor_catalog_uuid", None) + device_catalog_uuids = attrs.pop("device_catalog_uuids", None) + catalog_uuids = [] + if sensor_catalog_uuid is not None: + catalog_uuids.append(sensor_catalog_uuid) + if device_catalog_uuids: + catalog_uuids.extend(device_catalog_uuids) + + if catalog_uuids: + try: + catalog_map = { + catalog.uuid: catalog + for catalog in DeviceCatalog.objects.filter(uuid__in=catalog_uuids) + } + except DeviceCatalog.DoesNotExist as exc: + raise serializers.ValidationError({"device_catalog_uuids": ["Device catalog not found."]}) from exc + if len(catalog_map) != len({uuid for uuid in catalog_uuids}): + raise serializers.ValidationError({"device_catalog_uuids": ["One or more device catalogs were not found."]}) + device_catalogs = [catalog_map[uuid] for uuid in dict.fromkeys(catalog_uuids)] + attrs["sensor_catalog"] = device_catalogs[0] + attrs["device_catalogs"] = device_catalogs + attrs.setdefault("name", device_catalogs[0].name) + + return attrs + + +class FarmHubCreateSerializer(serializers.ModelSerializer): + area_geojson = serializers.JSONField(write_only=True, required=False) + farm_boundary = serializers.JSONField(write_only=True, required=False) + farm_type_uuid = serializers.UUIDField(write_only=True) + subscription_plan_uuid = serializers.UUIDField(write_only=True, required=False, allow_null=True) + product_uuids = serializers.ListField( + child=serializers.UUIDField(), + write_only=True, + allow_empty=False, + ) + sensors = FarmDeviceWriteSerializer(many=True, required=False) + sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1") + sensor_payload = serializers.JSONField(write_only=True, required=False) + irrigation_method_id = serializers.IntegerField(required=False, allow_null=True) + irrigation_method_name = serializers.CharField(required=False, allow_blank=True) + + class Meta: + model = FarmHub + fields = [ + "name", + "is_active", + "farm_type_uuid", + "subscription_plan_uuid", + "product_uuids", + "sensors", + "area_geojson", + "farm_boundary", + "sensor_key", + "sensor_payload", + "irrigation_method_id", + "irrigation_method_name", + ] + + def to_internal_value(self, data): + if hasattr(data, "copy"): + data = data.copy() + data.pop("farm_uuid", None) + return super().to_internal_value(data) + + def validate_area_geojson(self, value): + try: + return normalize_farm_boundary_input(value) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + def validate_farm_boundary(self, value): + try: + return normalize_farm_boundary_input(value) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + + def validate_sensor_payload(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("`sensor_payload` must be an object.") + return value + + def validate(self, attrs): + farm_boundary = attrs.pop("farm_boundary", serializers.empty) + if farm_boundary is not serializers.empty: + attrs["area_geojson"] = farm_boundary + + farm_type_uuid = attrs.get("farm_type_uuid") + subscription_plan_uuid = attrs.get("subscription_plan_uuid", serializers.empty) + product_uuids = attrs.get("product_uuids") + + if farm_type_uuid is None: + if self.instance is None: + raise serializers.ValidationError({"farm_type_uuid": ["This field is required."]}) + farm_type = self.instance.farm_type + else: + try: + farm_type = FarmType.objects.get(uuid=farm_type_uuid) + except FarmType.DoesNotExist as exc: + raise serializers.ValidationError({"farm_type_uuid": ["Farm type not found."]}) from exc + + if product_uuids is None: + products = list(self.instance.products.all()) if self.instance is not None else [] + else: + products = list(Product.objects.filter(uuid__in=product_uuids)) + if len(products) != len(product_uuids): + raise serializers.ValidationError({"product_uuids": ["One or more products were not found."]}) + + invalid_products = [product.name for product in products if product.farm_type_id != farm_type.id] + if invalid_products: + raise serializers.ValidationError( + {"product_uuids": [f"Products must belong to farm type `{farm_type.name}`."]} + ) + + if subscription_plan_uuid is serializers.empty: + if self.instance is not None: + subscription_plan = self.instance.subscription_plan + else: + subscription_plan = SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).first() + elif subscription_plan_uuid is None: + subscription_plan = None + else: + try: + subscription_plan = SubscriptionPlan.objects.get(uuid=subscription_plan_uuid, is_active=True) + except SubscriptionPlan.DoesNotExist as exc: + raise serializers.ValidationError({"subscription_plan_uuid": ["Subscription plan not found."]}) from exc + + attrs["farm_type"] = farm_type + attrs["subscription_plan"] = subscription_plan + attrs["products"] = products + + irrigation_method_id = attrs.get("irrigation_method_id", serializers.empty) + irrigation_method_name = attrs.get("irrigation_method_name", serializers.empty) + if irrigation_method_id is None: + attrs["irrigation_method_name"] = "" + elif irrigation_method_name is serializers.empty and self.instance is not None: + attrs["irrigation_method_name"] = self.instance.irrigation_method_name + + return attrs + + def create(self, validated_data): + validated_data.pop("area_geojson", None) + validated_data.pop("sensor_key", None) + validated_data.pop("sensor_payload", None) + sensors_data = validated_data.pop("sensors", []) + products = validated_data.pop("products", []) + validated_data["farm_type"] = validated_data.pop("farm_type") + validated_data["subscription_plan"] = validated_data.pop("subscription_plan", None) + validated_data.pop("farm_type_uuid", None) + validated_data.pop("subscription_plan_uuid", None) + validated_data.pop("product_uuids", None) + + farm = super().create(validated_data) + if products: + farm.products.set(products) + if sensors_data: + created_devices = [] + for sensor_data in sensors_data: + device_catalogs = sensor_data.pop("device_catalogs", []) + farm_device = FarmDevice.objects.create(farm=farm, **sensor_data) + if device_catalogs: + farm_device.device_catalogs.set(device_catalogs) + created_devices.append(farm_device) + return farm + + def update(self, instance, validated_data): + validated_data.pop("area_geojson", None) + validated_data.pop("sensor_key", None) + validated_data.pop("sensor_payload", None) + sensors_data = validated_data.pop("sensors", None) + products = validated_data.pop("products", None) + farm_type = validated_data.pop("farm_type", None) + subscription_plan = validated_data.pop("subscription_plan", serializers.empty) + validated_data.pop("farm_type_uuid", None) + validated_data.pop("subscription_plan_uuid", None) + validated_data.pop("product_uuids", None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if farm_type is not None: + instance.farm_type = farm_type + if subscription_plan is not serializers.empty: + instance.subscription_plan = subscription_plan + instance.save() + + if products is not None: + instance.products.set(products) + if sensors_data is not None: + instance.sensors.all().delete() + if sensors_data: + for sensor_data in sensors_data: + device_catalogs = sensor_data.pop("device_catalogs", []) + farm_device = FarmDevice.objects.create(farm=instance, **sensor_data) + if device_catalogs: + farm_device.device_catalogs.set(device_catalogs) + + return instance + + +class FarmToggleSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField() diff --git a/Modules/Backend/farm_hub/services.py b/Modules/Backend/farm_hub/services.py new file mode 100644 index 0000000..ea512c1 --- /dev/null +++ b/Modules/Backend/farm_hub/services.py @@ -0,0 +1,194 @@ +import logging + +from django.conf import settings +from django.db import transaction + +from crop_zoning.services import ( + create_zones_and_dispatch, + get_default_area_feature, + get_initial_zones_payload, + normalize_area_feature, +) +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError +from plants.services import push_plants_to_ai + + +logger = logging.getLogger(__name__) + + +class FarmDataSyncError(Exception): + pass + + +def dispatch_farm_zoning(area_feature, farm): + crop_area, _zones = create_zones_and_dispatch(normalize_area_feature(area_feature), farm=farm) + return crop_area, get_initial_zones_payload(crop_area) + + +def normalize_farm_boundary_input(area_feature): + if area_feature is None: + return get_default_area_feature() + + if not isinstance(area_feature, dict): + raise ValueError("`farm_boundary` must be a GeoJSON object or corners payload.") + + corners = area_feature.get("corners") + if isinstance(corners, list) and corners: + ring = [] + for corner in corners: + if not isinstance(corner, dict): + raise ValueError("Each farm boundary corner must be an object.") + lat = corner.get("lat") + lon = corner.get("lon") + if lat is None or lon is None: + raise ValueError("Each farm boundary corner must include `lat` and `lon`.") + ring.append([float(lon), float(lat)]) + + if ring[0] != ring[-1]: + ring.append(ring[0]) + + area_feature = { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Polygon", "coordinates": [ring]}, + } + + return normalize_area_feature(area_feature) + + +def sync_farm_data( + *, + farm, + area_feature=None, + sensor_key="sensor-7-1", + sensor_payload=None, + plant_ids=None, + irrigation_method_id=None, +): + push_plants_to_ai() + request_payload = { + "farm_uuid": str(farm.farm_uuid), + "farm_boundary": _extract_boundary_geometry(area_feature, farm=farm), + } + + normalized_sensor_payload = _normalize_sensor_payload(sensor_key=sensor_key, sensor_payload=sensor_payload) + if normalized_sensor_payload: + request_payload["sensor_key"] = sensor_key or "sensor-7-1" + request_payload["sensor_payload"] = normalized_sensor_payload + + if plant_ids: + request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids] + + resolved_irrigation_method_id = irrigation_method_id + if resolved_irrigation_method_id is None: + resolved_irrigation_method_id = farm.irrigation_method_id + if resolved_irrigation_method_id is not None: + request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id) + + if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")): + raise FarmDataSyncError( + "At least one of `sensor_payload`, `plant_ids`, or `irrigation_method_id` is required for farm data sync." + ) + + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + logger.error("Farm data sync failed: FARM_DATA_API_KEY missing for farm_uuid=%s", farm.farm_uuid) + raise FarmDataSyncError("FARM_DATA_API_KEY is not configured.") + + logger.warning( + "Farm data sync start: farm_uuid=%s sensor_key=%s has_sensor_payload=%s plant_ids=%s irrigation_method_id=%s boundary_type=%s", + farm.farm_uuid, + request_payload.get("sensor_key"), + "sensor_payload" in request_payload, + request_payload.get("plant_ids"), + request_payload.get("irrigation_method_id"), + request_payload["farm_boundary"].get("type") if isinstance(request_payload["farm_boundary"], dict) else None, + ) + try: + response = external_api_request( + "ai", + _get_farm_data_path(), + method="POST", + payload=request_payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", + }, + ) + except ExternalAPIRequestError as exc: + logger.exception("Farm data sync request exception: farm_uuid=%s", farm.farm_uuid) + raise FarmDataSyncError(f"Farm data API request failed: {exc}") from exc + + if response.status_code >= 400: + response_body = response.data + logger.error( + "Farm data sync rejected: farm_uuid=%s status_code=%s response=%s", + farm.farm_uuid, + response.status_code, + response_body, + ) + raise FarmDataSyncError(f"Farm data API returned status {response.status_code}: {response_body}") + + logger.warning("Farm data sync success: farm_uuid=%s status_code=%s", farm.farm_uuid, response.status_code) + return request_payload + + +def create_farm_with_zoning(serializer, owner): + area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature() + sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1") + sensor_payload = serializer.validated_data.pop("sensor_payload", None) + irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None) + + with transaction.atomic(): + farm = serializer.save(owner=owner) + crop_area, zoning_payload = dispatch_farm_zoning(area_feature, farm) + farm.current_crop_area = crop_area + farm.save(update_fields=["current_crop_area", "updated_at"]) + sync_farm_data( + farm=farm, + area_feature=area_feature, + sensor_key=sensor_key, + sensor_payload=sensor_payload, + plant_ids=[product.id for product in farm.products.all()], + irrigation_method_id=irrigation_method_id, + ) + + return farm, zoning_payload + + +def _normalize_sensor_payload(*, sensor_key, sensor_payload): + if not sensor_payload: + return None + if not isinstance(sensor_payload, dict): + raise ValueError("`sensor_payload` must be an object.") + + normalized_sensor_key = sensor_key or "sensor-7-1" + if all(isinstance(value, dict) for value in sensor_payload.values()): + return sensor_payload + return {normalized_sensor_key: sensor_payload} + + +def _extract_boundary_geometry(area_feature, *, farm): + if area_feature is not None: + geometry = (area_feature.get("geometry") or {}) if area_feature.get("type") == "Feature" else area_feature + if geometry.get("type") != "Polygon": + raise FarmDataSyncError("Farm boundary geometry must be a Polygon.") + return geometry + + crop_area = farm.current_crop_area or farm.crop_areas.order_by("-created_at", "-id").first() + if crop_area is None: + raise FarmDataSyncError("Farm boundary is not configured for this farm.") + + geometry = crop_area.geometry or {} + if geometry.get("type") == "Feature": + geometry = geometry.get("geometry") or {} + if geometry.get("type") != "Polygon": + raise FarmDataSyncError("Farm boundary geometry must be a Polygon.") + return geometry + + +def _get_farm_data_path(): + return getattr(settings, "FARM_DATA_API_PATH", "/api/farm-data/") diff --git a/Modules/Backend/farm_hub/tests.py b/Modules/Backend/farm_hub/tests.py new file mode 100644 index 0000000..90e7352 --- /dev/null +++ b/Modules/Backend/farm_hub/tests.py @@ -0,0 +1,491 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory, force_authenticate +from unittest.mock import patch + +from access_control.models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan +from access_control.services import build_farm_access_profile +from access_control.views import FarmAccessProfileView +from crop_zoning.models import CropArea +from device_hub.models import DeviceCatalog +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType, Product +from farm_hub.serializers import FarmHubSerializer +from farm_hub.seeds import seed_admin_farm +from farm_hub.views import FarmDetailView, FarmListCreateView, FarmTypeListView, FarmTypeProductsView + + +AREA_GEOJSON = { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [51.418934, 35.706815], + [51.423054, 35.691062], + [51.384258, 35.689389], + [51.418934, 35.706815], + ] + ], + }, +} + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, + FARM_DATA_API_KEY="farm-data-key", +) +class FarmListCreateViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی") + self.wheat, _ = Product.objects.get_or_create(farm_type=self.farm_type, name="گندم") + self.plan = SubscriptionPlan.objects.create(code="gold", name="Gold") + self.weather_station, _ = DeviceCatalog.objects.get_or_create( + code="sensor_7_soil_moisture_sensor_v1_2", + name="Sensor 7 - Soil Moisture Sensor v1.2", + defaults={"supported_power_sources": ["solar", "direct_power"]}, + ) + + @patch("farm_hub.services.external_api_request") + def test_create_farm_with_area_geojson_creates_crop_zoning_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + physical_device_uuid = "33333333-3333-3333-3333-333333333333" + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-1", + "farm_type_uuid": str(self.farm_type.uuid), + "subscription_plan_uuid": str(self.plan.uuid), + "product_uuids": [str(self.wheat.uuid)], + "irrigation_method_id": 3, + "irrigation_method_name": "Drip", + "sensors": [ + { + "sensor_catalog_uuid": str(self.weather_station.uuid), + "physical_device_uuid": physical_device_uuid, + "name": "zone-sensor", + "sensor_type": "weather_station", + "specifications": {"model": "FH-1"}, + "power_source": {"type": "battery"}, + } + ], + "area_geojson": AREA_GEOJSON, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["code"], 201) + self.assertEqual(response.data["data"]["name"], "farm-1") + self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code) + self.assertEqual(response.data["data"]["irrigation_method_id"], 3) + self.assertEqual(response.data["data"]["irrigation_method_name"], "Drip") + self.assertIn("zoning", response.data["data"]) + self.assertIsNotNone(response.data["data"]["area_uuid"]) + self.assertEqual(len(response.data["data"]["sensors"]), 1) + self.assertEqual(response.data["data"]["sensors"][0]["sensor_catalog_uuid"], str(self.weather_station.uuid)) + self.assertEqual(response.data["data"]["sensors"][0]["physical_device_uuid"], physical_device_uuid) + self.assertGreater(response.data["data"]["zoning"]["zone_count"], 1) + self.assertEqual( + response.data["data"]["zoning"]["zone_count"], + CropArea.objects.get().zone_count, + ) + self.assertEqual(CropArea.objects.count(), 1) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/farm-data/", + method="POST", + payload={ + "farm_uuid": response.data["data"]["farm_uuid"], + "farm_boundary": AREA_GEOJSON["geometry"], + "plant_ids": [self.wheat.id], + "irrigation_method_id": 3, + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": "farm-data-key", + "Authorization": "Api-Key farm-data-key", + }, + ) + + @patch("farm_hub.services.external_api_request") + def test_create_farm_ignores_client_farm_uuid_and_generates_new_one(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + request = self.factory.post( + "/api/farm-hub/", + { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "name": "farm-2", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertNotEqual(response.data["data"]["farm_uuid"], "11111111-1111-1111-1111-111111111111") + self.assertIsNotNone(response.data["data"]["area_uuid"]) + + @patch("farm_hub.services.external_api_request") + def test_create_farm_rejects_unknown_sensor_catalog_uuid(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-3", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + "sensors": [ + { + "sensor_catalog_uuid": "44444444-4444-4444-4444-444444444444", + "name": "zone-sensor", + } + ], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("sensor_catalog_uuid", response.data["sensors"][0]) + + @patch("farm_hub.services.external_api_request") + def test_create_farm_defaults_to_gold_plan_when_not_provided(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-default-plan", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["data"]["subscription_plan"]["code"], "gold") + + def test_create_farm_rejects_non_object_sensor_payload(self): + request = self.factory.post( + "/api/farm-hub/", + { + "name": "farm-invalid-sensor-payload", + "farm_type_uuid": str(self.farm_type.uuid), + "product_uuids": [str(self.wheat.uuid)], + "sensor_payload": ["invalid"], + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["sensor_payload"], ["`sensor_payload` must be an object."]) + + @patch("farm_hub.services.external_api_request") + def test_patch_farm_forwards_farm_data_fields(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=201, data={}) + farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="patch-target", + ) + farm.products.add(self.wheat) + + request = self.factory.patch( + f"/api/farm-hub/{farm.farm_uuid}/", + { + "farm_boundary": { + "corners": [ + {"lat": 35.70, "lon": 51.39}, + {"lat": 35.70, "lon": 51.41}, + {"lat": 35.72, "lon": 51.41}, + {"lat": 35.72, "lon": 51.39}, + ] + }, + "sensor_payload": {"soil_moisture": 45.2}, + "irrigation_method_id": 3, + "irrigation_method_name": "Drip", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmDetailView.as_view()(request, farm_uuid=farm.farm_uuid) + + self.assertEqual(response.status_code, 200) + farm.refresh_from_db() + self.assertIsNotNone(farm.current_crop_area) + self.assertEqual(farm.irrigation_method_id, 3) + self.assertEqual(farm.irrigation_method_name, "Drip") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/farm-data/", + method="POST", + payload={ + "farm_uuid": str(farm.farm_uuid), + "farm_boundary": { + "type": "Polygon", + "coordinates": [ + [ + [51.39, 35.7], + [51.41, 35.7], + [51.41, 35.72], + [51.39, 35.72], + [51.39, 35.7], + ] + ], + }, + "sensor_key": "sensor-7-1", + "sensor_payload": { + "sensor-7-1": {"soil_moisture": 45.2}, + }, + "plant_ids": [self.wheat.id], + "irrigation_method_id": 3, + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": "farm-data-key", + "Authorization": "Api-Key farm-data-key", + }, + ) + + +@override_settings( + USE_EXTERNAL_API_MOCK=True, + CROP_ZONE_CHUNK_AREA_SQM=200000, +) +class FarmSeedTests(TestCase): + def test_seed_admin_farm_dispatches_crop_logic_flow_on_create(self): + farm, created = seed_admin_farm() + + self.assertTrue(created) + self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111") + self.assertEqual(CropArea.objects.count(), 1) + self.assertEqual(farm.sensors.count(), 1) + self.assertEqual(farm.irrigation_method_id, 1) + self.assertEqual(farm.irrigation_method_name, "آبیاری قطره ای") + self.assertIsNotNone(farm.sensors.first().physical_device_uuid) + self.assertTrue(DeviceCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists()) + + def test_seed_admin_farm_does_not_dispatch_twice_for_existing_seed(self): + first_farm, first_created = seed_admin_farm() + second_farm, second_created = seed_admin_farm() + + self.assertTrue(first_created) + self.assertFalse(second_created) + self.assertEqual(first_farm.id, second_farm.id) + self.assertEqual(CropArea.objects.count(), 1) + + +class FarmCatalogViewsTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="catalog-user", + password="secret123", + email="catalog@example.com", + phone_number="09120000001", + ) + self.field_farm_type = FarmType.objects.create(name="زراعی") + self.tree_farm_type = FarmType.objects.create(name="درختی") + self.wheat = Product.objects.create( + farm_type=self.field_farm_type, + name="گندم", + planting_season="پاییز", + harvest_time="بهار", + health_profile={"moisture": {"ideal_value": 65}}, + ) + self.corn = Product.objects.create(farm_type=self.field_farm_type, name="ذرت") + Product.objects.create(farm_type=self.tree_farm_type, name="سیب") + + def test_farm_type_list_returns_all_farm_types(self): + request = self.factory.get("/api/farm-hub/farm-types/") + force_authenticate(request, user=self.user) + + response = FarmTypeListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 2) + + def test_farm_type_products_returns_products_for_selected_type(self): + request = self.factory.get(f"/api/farm-hub/farm-types/{self.field_farm_type.uuid}/products/") + force_authenticate(request, user=self.user) + + response = FarmTypeProductsView.as_view()(request, farm_type_uuid=self.field_farm_type.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual({item["name"] for item in response.data["data"]}, {self.wheat.name, self.corn.name}) + wheat_payload = next(item for item in response.data["data"] if item["name"] == self.wheat.name) + self.assertEqual(wheat_payload["planting_season"], "پاییز") + self.assertEqual(wheat_payload["health_profile"]["moisture"]["ideal_value"], 65) + + def test_farm_type_products_returns_404_for_unknown_type(self): + unknown_farm_type_uuid = "11111111-1111-1111-1111-111111111111" + request = self.factory.get(f"/api/farm-hub/farm-types/{unknown_farm_type_uuid}/products/") + force_authenticate(request, user=self.user) + + response = FarmTypeProductsView.as_view()(request, farm_type_uuid=unknown_farm_type_uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm type not found.") + + +class FarmAccessProfileTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="feature-user", + password="secret123", + email="feature@example.com", + phone_number="09120000002", + ) + self.plan = SubscriptionPlan.objects.create(code="starter", name="Starter") + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.product = Product.objects.create(farm_type=self.farm_type, name="خیار") + self.sensor_catalog = DeviceCatalog.objects.create(code="climate_sensor", name="Climate Sensor") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Feature Farm", + ) + self.farm.products.add(self.product) + self.farm.sensors.create(name="Climate Node", sensor_catalog=self.sensor_catalog, sensor_type="climate") + + self.greenhouse_dashboard = AccessFeature.objects.create( + code="greenhouse-dashboard", + name="Greenhouse Dashboard", + feature_type=AccessFeature.PAGE, + ) + self.sensor_analytics = AccessFeature.objects.create( + code="sensor-analytics", + name="Sensor Analytics", + feature_type=AccessFeature.WIDGET, + ) + self.legacy_reports = AccessFeature.objects.create( + code="legacy-reports", + name="Legacy Reports", + feature_type=AccessFeature.PAGE, + default_enabled=True, + ) + + plan_rule = AccessRule.objects.create(code="starter-greenhouse", name="Starter Greenhouse", priority=10) + plan_rule.features.add(self.greenhouse_dashboard) + plan_rule.subscription_plans.add(self.plan) + plan_rule.farm_types.add(self.farm_type) + + sensor_rule = AccessRule.objects.create(code="sensor-analytics-rule", name="Sensor Analytics", priority=20) + sensor_rule.features.add(self.sensor_analytics) + sensor_rule.sensor_catalogs.add(self.sensor_catalog) + + deny_rule = AccessRule.objects.create( + code="hide-legacy-reports", + name="Hide Legacy Reports", + priority=30, + effect=AccessRule.DENY, + ) + deny_rule.features.add(self.legacy_reports) + deny_rule.products.add(self.product) + + def test_build_farm_access_profile_resolves_combined_rules(self): + profile = build_farm_access_profile(self.farm) + + self.assertEqual(profile["subscription_plan"]["code"], self.plan.code) + self.assertTrue(profile["features"]["greenhouse-dashboard"]["enabled"]) + self.assertTrue(profile["features"]["sensor-analytics"]["enabled"]) + self.assertFalse(profile["features"]["legacy-reports"]["enabled"]) + self.assertEqual(profile["features"]["legacy-reports"]["source"], "hide-legacy-reports") + self.assertEqual(len(profile["matched_rules"]), 3) + + def test_access_profile_view_returns_grouped_features(self): + request = self.factory.get(f"/api/access-control/farms/{self.farm.farm_uuid}/profile/") + force_authenticate(request, user=self.user) + + response = FarmAccessProfileView.as_view()(request, farm_uuid=self.farm.farm_uuid) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("features", response.data["data"]) + self.assertNotIn("groups", response.data["data"]) + self.assertEqual(len(response.data["data"]["matched_rules"]), 3) + self.assertTrue(FarmAccessProfile.objects.filter(farm=self.farm).exists()) + + def test_sensor_rule_can_match_by_metadata_sensor_code(self): + sensor_page = AccessFeature.objects.create( + code="sensor-page", + name="Sensor Page", + feature_type=AccessFeature.PAGE, + ) + sensor_rule = AccessRule.objects.create( + code="sensor-page-by-code", + name="Sensor Page By Code", + priority=40, + metadata={"sensor_catalog_codes": [self.sensor_catalog.code]}, + ) + sensor_rule.features.add(sensor_page) + + profile = build_farm_access_profile(self.farm) + + self.assertTrue(profile["features"]["sensor-page"]["enabled"]) + + def test_build_farm_access_profile_falls_back_to_default_plan(self): + default_plan = SubscriptionPlan.objects.create(code="gold", name="Gold", metadata={"is_default": True}) + fallback_farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=None, + name="Fallback Plan Farm", + ) + fallback_farm.products.add(self.product) + fallback_feature = AccessFeature.objects.create( + code="fallback-dashboard", + name="Fallback Dashboard", + feature_type=AccessFeature.PAGE, + ) + fallback_rule = AccessRule.objects.create(code="gold-fallback-rule", name="Gold Fallback Rule", priority=5) + fallback_rule.features.add(fallback_feature) + fallback_rule.subscription_plans.add(default_plan) + + profile = build_farm_access_profile(fallback_farm) + + self.assertEqual(profile["subscription_plan"]["code"], "gold") + self.assertTrue(profile["features"]["fallback-dashboard"]["enabled"]) + + def test_farm_serializer_returns_default_plan_when_model_plan_is_null(self): + SubscriptionPlan.objects.create(code="gold", name="Gold", metadata={"is_default": True}) + fallback_farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=None, + name="Serializer Fallback Farm", + ) + + payload = FarmHubSerializer(fallback_farm).data + + self.assertEqual(payload["subscription_plan"]["code"], "gold") diff --git a/Modules/Backend/farm_hub/urls.py b/Modules/Backend/farm_hub/urls.py new file mode 100644 index 0000000..6a9d697 --- /dev/null +++ b/Modules/Backend/farm_hub/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + FarmActiveView, + FarmDeactiveView, + FarmDetailView, + FarmListCreateView, + FarmTypeListView, + FarmTypeProductsView, +) + +urlpatterns = [ + path("active/", FarmActiveView.as_view(), name="farm-hub-active"), + path("deactive/", FarmDeactiveView.as_view(), name="farm-hub-deactive"), + path("farm-types/", FarmTypeListView.as_view(), name="farm-type-list"), + path("farm-types//products/", FarmTypeProductsView.as_view(), name="farm-type-products"), + path("/", FarmDetailView.as_view(), name="farm-hub-detail"), + path("", FarmListCreateView.as_view(), name="farm-hub-list"), +] diff --git a/Modules/Backend/farm_hub/views.py b/Modules/Backend/farm_hub/views.py new file mode 100644 index 0000000..a7fea33 --- /dev/null +++ b/Modules/Backend/farm_hub/views.py @@ -0,0 +1,217 @@ +from django.db import transaction +from django.core.exceptions import ImproperlyConfigured +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema + +from config.swagger import code_response +from .models import FarmHub, FarmType, Product +from .serializers import ( + FarmHubCreateSerializer, + FarmHubSerializer, + FarmToggleSerializer, + FarmTypeSerializer, + ProductSerializer, +) +from .services import FarmDataSyncError, create_farm_with_zoning, dispatch_farm_zoning, sync_farm_data + + +class FarmHubBaseView(APIView): + permission_classes = [IsAuthenticated] + + def _get_farm(self, request, farm_uuid): + try: + return FarmHub.objects.prefetch_related("products", "sensors", "sensors__sensor_catalog", "sensors__device_catalogs").select_related( + "farm_type", + "subscription_plan", + "current_crop_area", + ).get( + farm_uuid=farm_uuid, + owner=request.user, + ) + except FarmHub.DoesNotExist: + return None + + +class FarmListCreateView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + responses={200: code_response("FarmListResponse", data=FarmHubSerializer(many=True))}, + ) + def get(self, request): + farms = FarmHub.objects.filter(owner=request.user).select_related( + "farm_type", + "subscription_plan", + "current_crop_area", + ).prefetch_related( + "products", + "sensors", + "sensors__sensor_catalog", + "sensors__device_catalogs", + ) + data = FarmHubSerializer(farms, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + request=FarmHubCreateSerializer, + responses={201: code_response("FarmCreateResponse", data=FarmHubSerializer())}, + ) + def post(self, request): + serializer = FarmHubCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + try: + farm, zoning_payload = create_farm_with_zoning(serializer, owner=request.user) + except ValueError as exc: + raise serializers.ValidationError({"area_geojson": [str(exc)]}) from exc + except FarmDataSyncError as exc: + return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + except ImproperlyConfigured as exc: + return Response({"code": 500, "msg": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + data = FarmHubSerializer(farm).data + if zoning_payload is not None: + data["zoning"] = zoning_payload + return Response({"code": 201, "msg": "success", "data": data}, status=status.HTTP_201_CREATED) + + +class FarmTypeListView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + responses={200: code_response("FarmTypeListResponse", data=FarmTypeSerializer(many=True))}, + ) + def get(self, request): + farm_types = FarmType.objects.order_by("name") + data = FarmTypeSerializer(farm_types, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class FarmTypeProductsView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + responses={ + 200: code_response("FarmTypeProductsResponse", data=ProductSerializer(many=True)), + 404: code_response("FarmTypeProductsNotFoundResponse"), + }, + ) + def get(self, request, farm_type_uuid): + try: + farm_type = FarmType.objects.get(uuid=farm_type_uuid) + except FarmType.DoesNotExist: + return Response({"code": 404, "msg": "Farm type not found."}, status=status.HTTP_404_NOT_FOUND) + + products = Product.objects.filter(farm_type=farm_type).order_by("name") + data = ProductSerializer(products, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class FarmDetailView(FarmHubBaseView): + @extend_schema( + tags=["Farm Hub"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, default="11111111-1111-1111-1111-111111111111"), + ], + responses={ + 200: code_response("FarmDetailResponse", data=FarmHubSerializer()), + 404: code_response("FarmNotFoundResponse"), + }, + ) + def get(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + data = FarmHubSerializer(farm).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, default="11111111-1111-1111-1111-111111111111"), + ], + request=FarmHubCreateSerializer, + responses={ + 200: code_response("FarmUpdateResponse", data=FarmHubSerializer()), + 404: code_response("FarmUpdateNotFoundResponse"), + }, + ) + def patch(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + serializer = FarmHubCreateSerializer(farm, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + area_feature = serializer.validated_data.get("area_geojson", None) + sensor_key = serializer.validated_data.get("sensor_key", "sensor-7-1") + sensor_payload = serializer.validated_data.get("sensor_payload", None) + irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None) + try: + with transaction.atomic(): + serializer.save() + if area_feature is not None: + crop_area, _zoning_payload = dispatch_farm_zoning(area_feature, serializer.instance) + serializer.instance.current_crop_area = crop_area + serializer.instance.save(update_fields=["current_crop_area", "updated_at"]) + sync_farm_data( + farm=serializer.instance, + area_feature=area_feature, + sensor_key=sensor_key, + sensor_payload=sensor_payload, + plant_ids=[product.id for product in serializer.instance.products.all()], + irrigation_method_id=irrigation_method_id, + ) + except FarmDataSyncError as exc: + return Response({"code": 502, "msg": str(exc)}, status=status.HTTP_502_BAD_GATEWAY) + farm.refresh_from_db() + data = FarmHubSerializer(farm).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Farm Hub"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, default="11111111-1111-1111-1111-111111111111"), + ], + responses={ + 200: code_response("FarmDeleteResponse"), + 404: code_response("FarmDeleteNotFoundResponse"), + }, + ) + def delete(self, request, farm_uuid): + farm = self._get_farm(request, farm_uuid) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + farm.delete() + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + +class FarmToggleView(FarmHubBaseView): + action = None + + @extend_schema( + tags=["Farm Hub"], + request=FarmToggleSerializer, + responses={ + 200: code_response("FarmToggleResponse"), + 400: code_response("FarmToggleValidationResponse"), + 404: code_response("FarmToggleNotFoundResponse"), + }, + ) + def post(self, request): + serializer = FarmToggleSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + farm.is_active = self.action == "active" + farm.save(update_fields=["is_active", "updated_at"]) + return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK) + + +class FarmActiveView(FarmToggleView): + action = "active" + + +class FarmDeactiveView(FarmToggleView): + action = "deactive" diff --git a/Modules/Backend/farmer_calendar/FARMER_CALENDAR_API.md b/Modules/Backend/farmer_calendar/FARMER_CALENDAR_API.md new file mode 100644 index 0000000..c8adbb6 --- /dev/null +++ b/Modules/Backend/farmer_calendar/FARMER_CALENDAR_API.md @@ -0,0 +1,392 @@ +# Farmer Calendar API + +این فایل مستندات کامل APIهای اپ `farmer_calendar` را توضیح می‌دهد. + +## Base Path + +تمام endpointهای این اپ با این prefix در دسترس هستند: + +```text +/api/events/ +``` + +## Authentication + +همه endpointهای این اپ نیاز به احراز هویت دارند. + +- Permission: `IsAuthenticated` +- Authentication: بر اساس تنظیمات DRF پروژه، معمولاً JWT + +هدر معمول: + +```text +Authorization: Bearer +``` + +## Data Model + +موجودیت اصلی در این اپ `FarmerCalendarEvent` است. + +فیلدهای مهم: + +- `id`: شناسه عمومی event از نوع UUID +- `title`: عنوان event +- `description`: توضیحات +- `deadline`: مهلت به صورت timestamp عددی +- `start`: زمان شروع event +- `end`: زمان پایان event +- `extendedProps`: داده‌های اضافه به صورت object +- `tags`: لیست tagها + +## Tag Rules + +در نسخه فعلی، `tags` از دیتابیس خوانده نمی‌شوند و فقط از enum داخلی پروژه مجاز هستند. + +نمونه tagهای مجاز: + +- `آبیاری` +- `آفت` +- `فوری` +- `روزانه` +- `ثبت دستی` +- `بازدید` +- `کوددهی` +- `سمپاشی` +- `برداشت` + +اگر tag خارج از enum ارسال شود، request با خطای validation رد می‌شود. + +## Priority + +در مدل مشترک event/todo، اولویت به صورت enum تعریف شده است. + +مقادیر مجاز: + +- `زیاد` +- `متوسط` +- `کم` + +یا در بعضی serializerهای مرتبط، ورودی انگلیسی: + +- `high` +- `medium` +- `low` + +## Endpoints + +### 1) List Events + +```http +GET /api/events/ +``` + +#### Query Params + +- `start`: فیلتر از این datetime به بعد +- `end`: فیلتر تا این datetime +- `farm_uuid`: اگر کاربر چند مزرعه داشته باشد، برای محدود کردن نتایج به یک مزرعه + +#### Behavior + +- فقط eventهای متعلق به farmهای کاربر login شده را برمی‌گرداند +- اگر `start` ارسال شود، eventهایی برمی‌گردند که `end >= start` +- اگر `end` ارسال شود، eventهایی برمی‌گردند که `start <= end` +- خروجی بر اساس `start` و بعد `created_at` مرتب می‌شود + +#### Sample Request + +```http +GET /api/events/?farm_uuid=&start=2025-02-24T00:00:00Z&end=2025-02-25T00:00:00Z +``` + +#### Sample Response + +```json +{ + "events": [ + { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "کنترل فشار و مدت زمان آبیاری", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T06:30:00Z", + "end": "2025-02-24T08:00:00Z", + "extendedProps": { + "source": "manual" + } + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 2) Create Event + +```http +POST /api/events/ +``` + +#### Request Body + +- `title`: اجباری، string +- `description`: اختیاری، string +- `deadline`: اختیاری، integer +- `tags`: اختیاری، array از tagهای enum +- `start`: اجباری، datetime +- `end`: اجباری، datetime +- `extendedProps`: اختیاری، object +- `farm_uuid`: اختیاری، اما اگر کاربر چند farm داشته باشد اجباری می‌شود + +#### Validation Rules + +- `title` نباید خالی باشد +- `extendedProps` باید object باشد +- `end` نباید از `start` کوچک‌تر باشد +- `tags` فقط باید از enum مجاز باشند +- اگر کاربر چند farm داشته باشد و `farm_uuid` نفرستد، خطا برمی‌گردد + +#### Sample Request + +```json +{ + "farm_uuid": "6b7ce8a8-13ec-4a6e-9118-7c298fd2a111", + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": { + "source": "manual" + } +} +``` + +#### Sample Success Response + +```json +{ + "event": { + "id": "7aa97f9f-bc4c-49f1-858f-11f3f433a111", + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": { + "source": "manual" + } + } +} +``` + +#### Sample Validation Error + +```json +{ + "code": "EVENT_VALIDATION_ERROR", + "message": "title cannot be empty", + "details": { + "title": ["title cannot be empty"] + } +} +``` + +--- + +### 3) Get Event Detail + +```http +GET /api/events// +``` + +#### Path Param + +- `event_uuid`: شناسه UUID رویداد + +#### Behavior + +- فقط اگر event متعلق به کاربر باشد برگردانده می‌شود +- اگر وجود نداشته باشد یا متعلق به کاربر دیگری باشد، `404` می‌دهد + +#### Sample Response + +```json +{ + "event": { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "کنترل فشار و مدت زمان آبیاری", + "deadline": 1734942600, + "tags": ["آبیاری"], + "start": "2025-02-24T06:30:00Z", + "end": "2025-02-24T08:00:00Z", + "extendedProps": {} + } +} +``` + +#### Sample Not Found Response + +```json +{ + "code": "EVENT_NOT_FOUND", + "message": "Event not found." +} +``` + +--- + +### 4) Update Event + +```http +PUT /api/events// +``` + +#### Request Body + +ساختار body مثل create است. + +#### Important Notes + +- این endpoint در حال حاضر update کامل انجام می‌دهد، نه partial +- اگر `farm_uuid` ارسال شود، نباید با farm فعلی event فرق داشته باشد +- اگر `tags` ارسال شوند، tagهای قبلی با همان لیست جدید جایگزین می‌شوند + +#### Sample Request + +```json +{ + "title": "آبیاری بلوک شمالی", + "description": "اولویت بالا", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {} +} +``` + +#### Sample Response + +```json +{ + "event": { + "id": "4be7c204-6fd8-4aa4-a5f4-7f0e9ceaa111", + "title": "آبیاری بلوک شمالی", + "description": "اولویت بالا", + "deadline": 1734942600, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {} + } +} +``` + +--- + +### 5) Delete Event + +```http +DELETE /api/events// +``` + +#### Sample Response + +```json +{ + "success": true +} +``` + +--- + +### 6) List Available Tags + +```http +GET /api/events/tags/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری؛ در نسخه فعلی فقط validate می‌شود ولی لیست tagها از enum داخلی برمی‌گردد + +#### Behavior + +- tagها از enum کد برمی‌گردند +- این endpoint دیگر به داده‌های tag در دیتابیس وابسته نیست + +#### Sample Response + +```json +{ + "tags": [ + { + "id": "tag_irrigation", + "label": "آبیاری", + "value": "آبیاری" + }, + { + "id": "tag_pest", + "label": "آفت", + "value": "آفت" + }, + { + "id": "tag_urgent", + "label": "فوری", + "value": "فوری" + } + ], + "meta": { + "total": 9 + } +} +``` + +## Error Format + +فرمت خطاهای validation به این شکل است: + +```json +{ + "code": "EVENT_VALIDATION_ERROR", + "message": "error message", + "details": {} +} +``` + +و خطای پیدا نشدن: + +```json +{ + "code": "EVENT_NOT_FOUND", + "message": "Event not found." +} +``` + +## Farm Resolution Rules + +رفتار `farm_uuid` در این اپ: + +- اگر کاربر فقط یک farm داشته باشد، در create می‌تواند `farm_uuid` نفرستد +- اگر کاربر چند farm داشته باشد، در create باید `farm_uuid` بفرستد +- اگر `farm_uuid` نامعتبر باشد، validation error برمی‌گردد +- در update، `farm_uuid` نباید farm event را تغییر دهد + +## Implementation Notes + +- فایل routeها: `farmer_calendar/urls.py` +- فایل viewها: `farmer_calendar/views.py` +- فایل serializerها: `farmer_calendar/serializers.py` +- enumهای tag و priority: `farmer_calendar/enums.py` + +## Related Note + +در ساختار فعلی پروژه، `farmer_calendar` و `farmer_todos` روی یک مدل مشترک سوار شده‌اند، ولی endpointهای این فایل فقط مربوط به مسیرهای `farmer_calendar` هستند. diff --git a/Modules/Backend/farmer_calendar/__init__.py b/Modules/Backend/farmer_calendar/__init__.py new file mode 100644 index 0000000..9c2d6ef --- /dev/null +++ b/Modules/Backend/farmer_calendar/__init__.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from datetime import date, datetime, time, timedelta + +from django.apps import apps as django_apps +from django.utils import timezone +from django.utils.dateparse import parse_date + +AUTO_PLAN_SOURCE = "auto_plan_sync" +PLAN_TYPE_IRRIGATION = "irrigation" +PLAN_TYPE_FERTILIZATION = "fertilization" + + +def create_event_for_farm( + *, + farm, + title, + description="", + start=None, + end=None, + scheduled_date=None, + event_time=None, + priority=None, + tags=None, + zone_value="برنامه خودکار", + extended_props=None, +): + FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent") + FarmerCalendarZone = django_apps.get_model("farmer_calendar", "FarmerCalendarZone") + from .enums import FarmerPriority + + if priority is None: + priority = FarmerPriority.MEDIUM + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + + payload = dict(extended_props or {}) + payload["tags"] = list(tags or []) + + return FarmerCalendarEvent.objects.create( + farm=farm, + zone=zone, + title=title, + description=description, + deadline=int(end.timestamp()) if end else None, + scheduled_date=scheduled_date, + time=event_time, + start=start, + end=end, + priority=priority, + status=FarmerCalendarEvent.STATUS_OPEN, + extended_props=payload, + ) + + +def delete_plan_events(*, farm, plan_type, plan_uuid): + FarmerCalendarEvent = django_apps.get_model("farmer_calendar", "FarmerCalendarEvent") + for event in FarmerCalendarEvent.objects.filter(farm=farm): + props = event.extended_props or {} + if ( + props.get("source") == AUTO_PLAN_SOURCE + and props.get("plan_type") == plan_type + and str(props.get("plan_uuid")) == str(plan_uuid) + ): + event.delete() + + +def sync_plan_events(plan, plan_type): + from .enums import FarmerPriority + + delete_plan_events(farm=plan.farm, plan_type=plan_type, plan_uuid=plan.uuid) + + if getattr(plan, "is_deleted", False) or not getattr(plan, "is_active", False): + return [] + + if plan_type == PLAN_TYPE_IRRIGATION: + items = _build_irrigation_events(plan) + elif plan_type == PLAN_TYPE_FERTILIZATION: + items = _build_fertilization_events(plan) + else: + items = [] + + created = [] + for index, item in enumerate(items, start=1): + created.append( + create_event_for_farm( + farm=plan.farm, + title=item["title"], + description=item.get("description", ""), + start=item.get("start"), + end=item.get("end"), + scheduled_date=item.get("scheduled_date"), + event_time=item.get("time"), + priority=item.get("priority", FarmerPriority.MEDIUM), + tags=item.get("tags", []), + zone_value=item.get("zone_value", "برنامه خودکار"), + extended_props={ + "source": AUTO_PLAN_SOURCE, + "plan_type": plan_type, + "plan_uuid": str(plan.uuid), + "plan_title": plan.title, + "entry_index": index, + **item.get("extended_props", {}), + }, + ) + ) + return created + + +def _build_irrigation_events(plan): + from .enums import FarmerPriority, FarmerTag + + payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {} + plan_data = payload.get("plan") if isinstance(payload.get("plan"), dict) else {} + water_balance = payload.get("water_balance") if isinstance(payload.get("water_balance"), dict) else {} + daily_entries = water_balance.get("daily") if isinstance(water_balance.get("daily"), list) else [] + + created = [] + for entry in daily_entries: + if not isinstance(entry, dict): + continue + scheduled = _parse_date(entry.get("forecast_date")) + if not scheduled: + continue + start_time, end_time = _parse_time_range(entry.get("irrigation_timing") or plan_data.get("bestTimeOfDay")) + start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes")) + gross_amount = entry.get("gross_irrigation_mm") + title = f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}" + description_parts = [] + if gross_amount not in (None, ""): + description_parts.append(f"مقدار آبیاری: {gross_amount} mm") + if plan_data.get("durationMinutes"): + description_parts.append(f"مدت زمان: {plan_data.get('durationMinutes')} دقیقه") + if entry.get("irrigation_timing"): + description_parts.append(f"بازه اجرا: {entry.get('irrigation_timing')}") + created.append( + { + "title": title, + "description": " | ".join(description_parts), + "scheduled_date": scheduled, + "time": start_time, + "start": start, + "end": end, + "priority": FarmerPriority.HIGH, + "tags": [FarmerTag.IRRIGATION.value], + "zone_value": "آبیاری", + "extended_props": { + "kind": "irrigation", + "gross_irrigation_mm": gross_amount, + "irrigation_timing": entry.get("irrigation_timing"), + }, + } + ) + + if created: + return created + + scheduled = timezone.localdate() + start_time, end_time = _parse_time_range(plan_data.get("bestTimeOfDay")) + start, end = _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=plan_data.get("durationMinutes")) + return [ + { + "title": f"آبیاری - {plan.crop_id or plan.title or 'مزرعه'}", + "description": f"برنامه فعال آبیاری: {plan.title}".strip(), + "scheduled_date": scheduled, + "time": start_time, + "start": start, + "end": end, + "priority": FarmerPriority.HIGH, + "tags": [FarmerTag.IRRIGATION.value], + "zone_value": "آبیاری", + "extended_props": {"kind": "irrigation_fallback"}, + } + ] + + +def _build_fertilization_events(plan): + from .enums import FarmerPriority, FarmerTag + + payload = plan.plan_payload if isinstance(plan.plan_payload, dict) else {} + primary = payload.get("primary_recommendation") if isinstance(payload.get("primary_recommendation"), dict) else {} + guide = payload.get("application_guide") if isinstance(payload.get("application_guide"), dict) else {} + steps = guide.get("steps") if isinstance(guide.get("steps"), list) else [] + interval = primary.get("application_interval") if isinstance(primary.get("application_interval"), dict) else {} + interval_days = _safe_int(interval.get("value")) + + base_date = timezone.localdate() + fertilizer_name = primary.get("display_title") or primary.get("fertilizer_name") or plan.title or "برنامه کودی" + created = [] + + for index, step in enumerate(steps): + if not isinstance(step, dict): + continue + scheduled = _extract_step_date(step) or (base_date + timedelta(days=(index * interval_days if interval_days else index))) + start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0))) + end = start + timedelta(minutes=30) + description = str(step.get("description") or guide.get("safety_warning") or "").strip() + created.append( + { + "title": f"کوددهی - {fertilizer_name}", + "description": description, + "scheduled_date": scheduled, + "time": start.time(), + "start": start, + "end": end, + "priority": FarmerPriority.MEDIUM, + "tags": [FarmerTag.FERTILIZATION.value], + "zone_value": "کوددهی", + "extended_props": { + "kind": "fertilization", + "step_number": step.get("step_number"), + "fertilizer_code": primary.get("fertilizer_code"), + }, + } + ) + + if created: + return created + + scheduled = base_date + start = timezone.make_aware(datetime.combine(scheduled, time(hour=8, minute=0))) + end = start + timedelta(minutes=30) + interval_label = interval.get("label") or "" + description = " | ".join(part for part in [str(primary.get("summary") or "").strip(), str(interval_label).strip()] if part) + return [ + { + "title": f"کوددهی - {fertilizer_name}", + "description": description, + "scheduled_date": scheduled, + "time": start.time(), + "start": start, + "end": end, + "priority": FarmerPriority.MEDIUM, + "tags": [FarmerTag.FERTILIZATION.value], + "zone_value": "کوددهی", + "extended_props": { + "kind": "fertilization_fallback", + "fertilizer_code": primary.get("fertilizer_code"), + }, + } + ] + + +def _parse_date(value): + if isinstance(value, date): + return value + if not value: + return None + return parse_date(str(value)) + + +def _parse_time_range(value): + if not value: + return None, None + raw = str(value).replace("تا", "-").replace("–", "-") + parts = [part.strip() for part in raw.split("-") if part.strip()] + if not parts: + return None, None + start_time = _parse_time(parts[0]) + end_time = _parse_time(parts[1]) if len(parts) > 1 else None + return start_time, end_time + + +def _parse_time(value): + if isinstance(value, time): + return value + if not value: + return None + cleaned = str(value).strip() + for fmt in ("%H:%M", "%H:%M:%S"): + try: + return datetime.strptime(cleaned, fmt).time() + except ValueError: + continue + return None + + +def _build_datetimes(scheduled, start_time, end_time, default_duration_minutes=None): + if scheduled is None: + return None, None + if start_time is None: + start_time = time(hour=6, minute=0) + start = timezone.make_aware(datetime.combine(scheduled, start_time)) + if end_time is not None: + end = timezone.make_aware(datetime.combine(scheduled, end_time)) + else: + end = start + timedelta(minutes=_safe_int(default_duration_minutes) or 30) + return start, end + + +def _extract_step_date(step): + for key in ("date", "scheduled_date", "application_date", "target_date", "forecast_date"): + parsed = _parse_date(step.get(key)) + if parsed: + return parsed + return None + + +def _safe_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/Modules/Backend/farmer_calendar/apps.py b/Modules/Backend/farmer_calendar/apps.py new file mode 100644 index 0000000..32f203f --- /dev/null +++ b/Modules/Backend/farmer_calendar/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmerCalendarConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farmer_calendar" + verbose_name = "Farmer Calendar" diff --git a/Modules/Backend/farmer_calendar/enums.py b/Modules/Backend/farmer_calendar/enums.py new file mode 100644 index 0000000..709b22b --- /dev/null +++ b/Modules/Backend/farmer_calendar/enums.py @@ -0,0 +1,41 @@ +from django.db import models + + +class FarmerPriority(models.TextChoices): + HIGH = "زیاد", "High" + MEDIUM = "متوسط", "Medium" + LOW = "کم", "Low" + + +class FarmerTag(models.TextChoices): + IRRIGATION = "آبیاری", "آبیاری" + PEST = "آفت", "آفت" + URGENT = "فوری", "فوری" + DAILY = "روزانه", "روزانه" + MANUAL = "ثبت دستی", "ثبت دستی" + VISIT = "بازدید", "بازدید" + FERTILIZATION = "کوددهی", "کوددهی" + SPRAYING = "سمپاشی", "سمپاشی" + HARVEST = "برداشت", "برداشت" + + +FARMER_TAG_CHOICES = [(tag.value, tag.label) for tag in FarmerTag] +FARMER_TAG_VALUES = {tag.value for tag in FarmerTag} +FARMER_TAG_ITEMS = [ + { + "id": f"tag_{tag.name.lower()}", + "label": tag.label, + "value": tag.value, + } + for tag in FarmerTag +] + + +PRIORITY_INPUT_MAP = { + "high": FarmerPriority.HIGH, + "medium": FarmerPriority.MEDIUM, + "low": FarmerPriority.LOW, + FarmerPriority.HIGH.value: FarmerPriority.HIGH, + FarmerPriority.MEDIUM.value: FarmerPriority.MEDIUM, + FarmerPriority.LOW.value: FarmerPriority.LOW, +} diff --git a/Modules/Backend/farmer_calendar/migrations/0001_initial.py b/Modules/Backend/farmer_calendar/migrations/0001_initial.py new file mode 100644 index 0000000..f0593b4 --- /dev/null +++ b/Modules/Backend/farmer_calendar/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.5 on 2025-02-24 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.CreateModel( + name="FarmerCalendarTag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=100)), + ("value", models.CharField(max_length=100)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calendar_tags", to="farm_hub.farmhub"), + ), + ], + options={ + "db_table": "farmer_calendar_tags", + "ordering": ["label"], + }, + ), + migrations.CreateModel( + name="FarmerCalendarEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("deadline", models.BigIntegerField(blank=True, null=True)), + ("start", models.DateTimeField()), + ("end", models.DateTimeField()), + ("extended_props", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="calendar_events", to="farm_hub.farmhub"), + ), + ( + "tags", + models.ManyToManyField(blank=True, related_name="events", to="farmer_calendar.farmercalendartag"), + ), + ], + options={ + "db_table": "farmer_calendar_events", + "ordering": ["start", "created_at"], + }, + ), + migrations.AddConstraint( + model_name="farmercalendartag", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_calendar_tag_per_farm"), + ), + ] diff --git a/Modules/Backend/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py b/Modules/Backend/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py new file mode 100644 index 0000000..e462441 --- /dev/null +++ b/Modules/Backend/farmer_calendar/migrations/0002_add_zone_and_todo_fields.py @@ -0,0 +1,209 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +def _table_exists(connection, table_name): + with connection.cursor() as cursor: + return table_name in connection.introspection.table_names(cursor) + + +def _column_names(connection, table_name): + with connection.cursor() as cursor: + description = connection.introspection.get_table_description(cursor, table_name) + return {column.name for column in description} + + +def _constraint_names(connection, table_name): + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints(cursor, table_name) + return set(constraints.keys()) + + +def sync_farmer_calendar_schema(apps, schema_editor): + connection = schema_editor.connection + zone_table = "farmer_calendar_zones" + event_table = "farmer_calendar_events" + + if not _table_exists(connection, zone_table): + schema_editor.execute( + """ + CREATE TABLE farmer_calendar_zones ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(32) NOT NULL UNIQUE, + label VARCHAR(255) NOT NULL, + value VARCHAR(255) NOT NULL, + is_active BOOL NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + farm_id BIGINT NOT NULL, + CONSTRAINT farmer_calendar_zones_farm_id_fk + FOREIGN KEY (farm_id) REFERENCES farm_hubs (id) + ) + """ + ) + + zone_constraints = _constraint_names(connection, zone_table) + if "uniq_farmer_calendar_zone_per_farm" not in zone_constraints: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_zones + ADD CONSTRAINT uniq_farmer_calendar_zone_per_farm UNIQUE (farm_id, value) + """ + ) + + event_columns = _column_names(connection, event_table) + if "priority" not in event_columns: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD COLUMN priority VARCHAR(16) NULL + """ + ) + if "scheduled_date" not in event_columns: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD COLUMN scheduled_date DATE NULL + """ + ) + if "status" not in event_columns: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'open' + """ + ) + if "time" not in event_columns: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD COLUMN time TIME NULL + """ + ) + if "zone_id" not in event_columns: + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD COLUMN zone_id BIGINT NULL + """ + ) + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + ADD CONSTRAINT farmer_calendar_events_zone_id_fk + FOREIGN KEY (zone_id) REFERENCES farmer_calendar_zones (id) + """ + ) + + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + MODIFY COLUMN start DATETIME(6) NULL + """ + ) + schema_editor.execute( + """ + ALTER TABLE farmer_calendar_events + MODIFY COLUMN end DATETIME(6) NULL + """ + ) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("farmer_calendar", "0001_initial"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython(sync_farmer_calendar_schema, noop_reverse), + ], + state_operations=[ + migrations.CreateModel( + name="FarmerCalendarZone", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=255)), + ("value", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="calendar_zones", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farmer_calendar_zones", + "ordering": ["label"], + }, + ), + migrations.AddField( + model_name="farmercalendarevent", + name="priority", + field=models.CharField( + blank=True, + choices=[("زیاد", "High"), ("متوسط", "Medium"), ("کم", "Low")], + max_length=16, + null=True, + ), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="scheduled_date", + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="status", + field=models.CharField(choices=[("open", "Open"), ("done", "Done")], default="open", max_length=16), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="time", + field=models.TimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="farmercalendarevent", + name="zone", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="events", + to="farmer_calendar.farmercalendarzone", + ), + ), + migrations.AlterField( + model_name="farmercalendarevent", + name="end", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name="farmercalendarevent", + name="start", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterModelOptions( + name="farmercalendarevent", + options={"db_table": "farmer_calendar_events", "ordering": ["scheduled_date", "start", "time", "created_at"]}, + ), + migrations.AddConstraint( + model_name="farmercalendarzone", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_calendar_zone_per_farm"), + ), + ], + ), + ] diff --git a/Modules/Backend/farmer_calendar/migrations/__init__.py b/Modules/Backend/farmer_calendar/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farmer_calendar/models.py b/Modules/Backend/farmer_calendar/models.py new file mode 100644 index 0000000..9b4dfbd --- /dev/null +++ b/Modules/Backend/farmer_calendar/models.py @@ -0,0 +1,84 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub +from .enums import FarmerPriority + + +class FarmerCalendarZone(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_zones") + label = models.CharField(max_length=255) + value = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_zones" + ordering = ["label"] + constraints = [ + models.UniqueConstraint(fields=["farm", "value"], name="uniq_farmer_calendar_zone_per_farm"), + ] + + def __str__(self): + return self.label + + +class FarmerCalendarTag(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_tags") + label = models.CharField(max_length=100) + value = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_tags" + ordering = ["label"] + constraints = [ + models.UniqueConstraint(fields=["farm", "value"], name="uniq_farmer_calendar_tag_per_farm"), + ] + + def __str__(self): + return self.label + + +class FarmerCalendarEvent(models.Model): + PRIORITY_HIGH = FarmerPriority.HIGH + PRIORITY_MEDIUM = FarmerPriority.MEDIUM + PRIORITY_LOW = FarmerPriority.LOW + PRIORITY_CHOICES = FarmerPriority.choices + + STATUS_OPEN = "open" + STATUS_DONE = "done" + STATUS_CHOICES = [ + (STATUS_OPEN, "Open"), + (STATUS_DONE, "Done"), + ] + + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey(FarmHub, on_delete=models.CASCADE, related_name="calendar_events") + zone = models.ForeignKey(FarmerCalendarZone, on_delete=models.PROTECT, related_name="events", null=True, blank=True) + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + deadline = models.BigIntegerField(null=True, blank=True) + scheduled_date = models.DateField(null=True, blank=True) + time = models.TimeField(null=True, blank=True) + start = models.DateTimeField(null=True, blank=True) + end = models.DateTimeField(null=True, blank=True) + priority = models.CharField(max_length=16, choices=PRIORITY_CHOICES, null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN) + extended_props = models.JSONField(default=dict, blank=True) + tags = models.ManyToManyField(FarmerCalendarTag, related_name="events", blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farmer_calendar_events" + ordering = ["scheduled_date", "start", "time", "created_at"] + + def __str__(self): + return self.title diff --git a/Modules/Backend/farmer_calendar/serializers.py b/Modules/Backend/farmer_calendar/serializers.py new file mode 100644 index 0000000..e044157 --- /dev/null +++ b/Modules/Backend/farmer_calendar/serializers.py @@ -0,0 +1,119 @@ +from rest_framework import serializers + +from .enums import FARMER_TAG_ITEMS, FARMER_TAG_VALUES +from .models import FarmerCalendarEvent + + +class FarmerCalendarEventResponseSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="uuid", read_only=True) + tags = serializers.SerializerMethodField() + extendedProps = serializers.SerializerMethodField() + + class Meta: + model = FarmerCalendarEvent + fields = [ + "id", + "title", + "description", + "deadline", + "tags", + "start", + "end", + "extendedProps", + ] + + def get_tags(self, obj): + raw_tags = obj.extended_props.get("tags", []) + return [tag for tag in raw_tags if tag in FARMER_TAG_VALUES] + + def get_extendedProps(self, obj): + extended_props = dict(obj.extended_props or {}) + extended_props.pop("tags", None) + return extended_props + + +class FarmerCalendarEventWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=255) + description = serializers.CharField(required=False, allow_blank=True, default="") + deadline = serializers.IntegerField(required=False, allow_null=True) + tags = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + allow_empty=True, + ) + start = serializers.DateTimeField() + end = serializers.DateTimeField() + extendedProps = serializers.JSONField(required=False, default=dict) + farm_uuid = serializers.UUIDField(required=False, write_only=True) + + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("title cannot be empty") + return value + + def validate_tags(self, value): + normalized = [] + for tag in value: + cleaned = tag.strip() + if cleaned: + if cleaned not in FARMER_TAG_VALUES: + raise serializers.ValidationError(f"tag `{cleaned}` is not valid") + normalized.append(cleaned) + return normalized + + def validate_extendedProps(self, value): + if not isinstance(value, dict): + raise serializers.ValidationError("extendedProps must be an object") + return value + + def validate(self, attrs): + if attrs["end"] < attrs["start"]: + raise serializers.ValidationError({"end": "end cannot be before start"}) + return attrs + + def create(self, validated_data): + tags = validated_data.pop("tags", []) + validated_data.pop("farm_uuid", None) + extended_props = validated_data.pop("extendedProps", {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + event = FarmerCalendarEvent.objects.create(**validated_data) + return event + + def update(self, instance, validated_data): + tags = validated_data.pop("tags", None) + validated_data.pop("farm_uuid", None) + if "extendedProps" in validated_data: + validated_data["extended_props"] = validated_data.pop("extendedProps") + if tags is not None: + extended_props = dict(instance.extended_props or {}) + if "extended_props" in validated_data: + extended_props.update(validated_data["extended_props"] or {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + +class FarmerCalendarListQuerySerializer(serializers.Serializer): + start = serializers.DateTimeField(required=False) + end = serializers.DateTimeField(required=False) + farm_uuid = serializers.UUIDField(required=False) + + def validate(self, attrs): + start = attrs.get("start") + end = attrs.get("end") + if start and end and end < start: + raise serializers.ValidationError({"end": "end cannot be before start"}) + return attrs + + +class FarmerCalendarTagIdSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + value = serializers.CharField() diff --git a/Modules/Backend/farmer_calendar/tests.py b/Modules/Backend/farmer_calendar/tests.py new file mode 100644 index 0000000..867e627 --- /dev/null +++ b/Modules/Backend/farmer_calendar/tests.py @@ -0,0 +1,167 @@ +from datetime import datetime, timezone + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from access_control.models import SubscriptionPlan +from farm_hub.models import FarmHub, FarmType + +from .models import FarmerCalendarEvent +from .views import EventDetailView, EventListCreateView, EventTagListView + + +class FarmerCalendarViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="calendar-user", + password="secret123", + email="calendar@example.com", + phone_number="09121111111", + ) + self.other_user = get_user_model().objects.create_user( + username="calendar-other", + password="secret123", + email="calendar-other@example.com", + phone_number="09122222222", + ) + self.plan = SubscriptionPlan.objects.create(code="calendar-plan", name="Calendar Plan") + self.farm_type = FarmType.objects.create(name="گلخانه") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Greenhouse A", + ) + self.other_farm = FarmHub.objects.create( + owner=self.other_user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Greenhouse B", + ) + self.event = FarmerCalendarEvent.objects.create( + farm=self.farm, + title="آبیاری بلوک شمالی", + description="کنترل فشار و مدت زمان آبیاری", + deadline=1734942600, + start=datetime(2025, 2, 24, 6, 30, tzinfo=timezone.utc), + end=datetime(2025, 2, 24, 8, 0, tzinfo=timezone.utc), + extended_props={"tags": ["آبیاری"]}, + ) + + def test_list_events_returns_expected_shape(self): + request = self.factory.get(f"/api/events/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["events"][0]["title"], "آبیاری بلوک شمالی") + self.assertEqual(response.data["events"][0]["tags"], ["آبیاری"]) + self.assertIn("T06:30:00Z", response.data["events"][0]["start"]) + + def test_create_event_creates_tags_and_event(self): + request = self.factory.post( + "/api/events/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "بازدید آفت در گلخانه", + "description": "بررسی وضعیت برگ ها و ثبت گزارش", + "deadline": 1734971400, + "tags": ["آفت", "فوری"], + "start": "2025-02-24T14:00:00Z", + "end": "2025-02-24T15:00:00Z", + "extendedProps": {"source": "manual"}, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["event"]["tags"], ["آفت", "فوری"]) + self.assertEqual(response.data["event"]["extendedProps"], {"source": "manual"}) + self.assertEqual(FarmerCalendarEvent.objects.filter(farm=self.farm).count(), 2) + self.assertEqual(response.data["event"]["tags"], ["آفت", "فوری"]) + + def test_update_event_supports_drag_and_resize_payload(self): + request = self.factory.put( + f"/api/events/{self.event.uuid}/", + { + "title": self.event.title, + "description": "اولویت بالا", + "deadline": self.event.deadline, + "tags": ["آبیاری", "فوری"], + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T16:00:00Z", + "extendedProps": {}, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=self.event.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["event"]["description"], "اولویت بالا") + self.assertIn("T15:00:00Z", response.data["event"]["start"]) + self.assertEqual(response.data["event"]["tags"], ["آبیاری", "فوری"]) + + def test_delete_event_returns_success(self): + request = self.factory.delete(f"/api/events/{self.event.uuid}/") + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=self.event.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"success": True}) + self.assertFalse(FarmerCalendarEvent.objects.filter(pk=self.event.pk).exists()) + + def test_tags_endpoint_returns_separate_list(self): + request = self.factory.get(f"/api/events/tags/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = EventTagListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tags"][0]["label"], "آبیاری") + self.assertEqual(response.data["tags"][0]["value"], "آبیاری") + + def test_validation_error_returns_message_and_details(self): + request = self.factory.post( + "/api/events/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "", + "start": "2025-02-24T15:00:00Z", + "end": "2025-02-24T14:00:00Z", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = EventListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "EVENT_VALIDATION_ERROR") + self.assertIn("message", response.data) + self.assertIn("details", response.data) + + def test_detail_rejects_foreign_event(self): + foreign_event = FarmerCalendarEvent.objects.create( + farm=self.other_farm, + title="foreign", + start=datetime(2025, 2, 24, 6, 30, tzinfo=timezone.utc), + end=datetime(2025, 2, 24, 8, 0, tzinfo=timezone.utc), + ) + request = self.factory.get(f"/api/events/{foreign_event.uuid}/") + force_authenticate(request, user=self.user) + + response = EventDetailView.as_view()(request, event_id=foreign_event.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["message"], "Event not found.") diff --git a/Modules/Backend/farmer_calendar/urls.py b/Modules/Backend/farmer_calendar/urls.py new file mode 100644 index 0000000..9fef6a8 --- /dev/null +++ b/Modules/Backend/farmer_calendar/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import EventDetailView, EventListCreateView, EventTagListView + +urlpatterns = [ + path("tags/", EventTagListView.as_view(), name="farmer-calendar-tag-list"), + path("/", EventDetailView.as_view(), name="farmer-calendar-detail"), + path("", EventListCreateView.as_view(), name="farmer-calendar-list-create"), +] diff --git a/Modules/Backend/farmer_calendar/views.py b/Modules/Backend/farmer_calendar/views.py new file mode 100644 index 0000000..96159ec --- /dev/null +++ b/Modules/Backend/farmer_calendar/views.py @@ -0,0 +1,164 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.exceptions import NotFound +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from farm_hub.models import FarmHub + +from .enums import FARMER_TAG_ITEMS +from .models import FarmerCalendarEvent +from .serializers import ( + FarmerCalendarEventResponseSerializer, + FarmerCalendarEventWriteSerializer, + FarmerCalendarListQuerySerializer, + FarmerCalendarTagIdSerializer, +) + + +class FarmerCalendarBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def error_response(message, details=None, code="EVENT_VALIDATION_ERROR", status_code=status.HTTP_400_BAD_REQUEST): + payload = { + "code": code, + "message": message, + } + if details is not None: + payload["details"] = details + return Response(payload, status=status_code) + + def handle_exception(self, exc): + if isinstance(exc, serializers.ValidationError): + details = exc.detail + message = "Invalid event payload" + if isinstance(details, dict): + first_value = next(iter(details.values()), None) + if isinstance(first_value, list) and first_value: + message = str(first_value[0]) + elif first_value: + message = str(first_value) + elif isinstance(details, list) and details: + message = str(details[0]) + return self.error_response(message=message, details=details) + if isinstance(exc, NotFound): + return self.error_response( + message=str(exc.detail), + code="EVENT_NOT_FOUND", + status_code=status.HTTP_404_NOT_FOUND, + ) + return super().handle_exception(exc) + + def _get_user_farms(self, request): + return FarmHub.objects.filter(owner=request.user).order_by("id") + + def _resolve_farm(self, request, farm_uuid=None, required=False): + farms = self._get_user_farms(request) + if farm_uuid: + try: + return farms.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + if required: + farm_count = farms.count() + if farm_count == 1: + return farms.first() + if farm_count == 0: + raise serializers.ValidationError({"farm_uuid": ["No farm found for this user."]}) + raise serializers.ValidationError({"farm_uuid": ["farm_uuid is required when multiple farms exist."]}) + return None + + def _get_event(self, request, event_id): + queryset = FarmerCalendarEvent.objects.select_related("farm").prefetch_related("tags") + try: + return queryset.get(uuid=event_id, farm__owner=request.user) + except FarmerCalendarEvent.DoesNotExist as exc: + raise NotFound("Event not found.") from exc + + +class EventListCreateView(FarmerCalendarBaseView): + @extend_schema( + tags=["Farmer Calendar"], + parameters=[ + OpenApiParameter(name="start", type=OpenApiTypes.DATETIME, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="end", type=OpenApiTypes.DATETIME, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + query_serializer = FarmerCalendarListQuerySerializer(data=request.query_params) + query_serializer.is_valid(raise_exception=True) + + queryset = FarmerCalendarEvent.objects.filter(farm__owner=request.user).prefetch_related("tags") + farm = self._resolve_farm(request, query_serializer.validated_data.get("farm_uuid"), required=False) + if farm is not None: + queryset = queryset.filter(farm=farm) + + start = query_serializer.validated_data.get("start") + end = query_serializer.validated_data.get("end") + if start: + queryset = queryset.filter(end__gte=start) + if end: + queryset = queryset.filter(start__lte=end) + + events = queryset.order_by("start", "created_at") + data = FarmerCalendarEventResponseSerializer(events, many=True).data + return Response({"events": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def post(self, request): + serializer = FarmerCalendarEventWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = self._resolve_farm(request, serializer.validated_data.get("farm_uuid"), required=True) + event = serializer.save(farm=farm) + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_201_CREATED) + + +class EventTagListView(FarmerCalendarBaseView): + @extend_schema( + tags=["Farmer Calendar"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + data = FarmerCalendarTagIdSerializer(FARMER_TAG_ITEMS, many=True).data + return Response({"tags": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class EventDetailView(FarmerCalendarBaseView): + @extend_schema(tags=["Farmer Calendar"]) + def get(self, request, event_id): + event = self._get_event(request, event_id) + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def put(self, request, event_id): + event = self._get_event(request, event_id) + serializer = FarmerCalendarEventWriteSerializer(event, data=request.data) + serializer.is_valid(raise_exception=True) + + requested_farm_uuid = serializer.validated_data.get("farm_uuid") + if requested_farm_uuid and str(event.farm.farm_uuid) != str(requested_farm_uuid): + return self.error_response( + message="farm_uuid cannot change an existing event", + details={"farm_uuid": ["farm_uuid cannot change an existing event"]}, + ) + + event = serializer.save() + data = FarmerCalendarEventResponseSerializer(event).data + return Response({"event": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Calendar"]) + def delete(self, request, event_id): + event = self._get_event(request, event_id) + event.delete() + return Response({"success": True}, status=status.HTTP_200_OK) diff --git a/Modules/Backend/farmer_todos/FARMER_TODOS_API.md b/Modules/Backend/farmer_todos/FARMER_TODOS_API.md new file mode 100644 index 0000000..3a4ca8e --- /dev/null +++ b/Modules/Backend/farmer_todos/FARMER_TODOS_API.md @@ -0,0 +1,507 @@ +# Farmer Todos API + +این فایل مستندات کامل APIهای اپ `farmer_todos` را توضیح می‌دهد. + +## Base Path + +تمام endpointهای این اپ با این prefix در دسترس هستند: + +```text +/api/farmer-todos/ +``` + +## Authentication + +همه endpointهای این اپ نیاز به احراز هویت دارند. + +- Permission: `IsAuthenticated` +- Authentication: بر اساس تنظیمات DRF پروژه، معمولاً JWT + +هدر معمول: + +```text +Authorization: Bearer +``` + +## Overview + +در ساختار فعلی پروژه، `farmer_todos` و `farmer_calendar` روی یک مدل مشترک سوار هستند، اما این اپ APIهای مخصوص todo را با فرمت مناسب frontend برمی‌گرداند. + +موجودیت اصلی: + +- `FarmerTodoTask` + +فیلدهای مهم response: + +- `id`: شناسه عمومی task از نوع UUID +- `title`: عنوان کار +- `zone`: نام ناحیه یا بخش +- `scheduledDate`: تاریخ انجام +- `time`: ساعت انجام +- `priority`: اولویت +- `note`: توضیح task +- `tags`: لیست tagها +- `status`: وضعیت + +## Enums + +### Priority + +اولویت‌ها enum-based هستند و از دیتابیس خوانده نمی‌شوند. + +مقادیر نهایی ذخیره‌شده: + +- `زیاد` +- `متوسط` +- `کم` + +ورودی‌های قابل قبول: + +- `high` +- `medium` +- `low` +- `زیاد` +- `متوسط` +- `کم` + +### Tags + +`tags` هم enum-based هستند و از دیتابیس خوانده نمی‌شوند. + +نمونه tagهای مجاز: + +- `آبیاری` +- `آفت` +- `فوری` +- `روزانه` +- `ثبت دستی` +- `بازدید` +- `کوددهی` +- `سمپاشی` +- `برداشت` + +اگر tag خارج از enum ارسال شود، request با validation error رد می‌شود. + +### Status + +مقادیر مجاز: + +- `open` +- `done` + +## Endpoints + +### 1) List Tasks + +```http +GET /api/farmer-todos/ +``` + +#### Query Params + +- `status`: فیلتر بر اساس وضعیت +- `priority`: فیلتر بر اساس اولویت +- `date`: فیلتر دقیق روی تاریخ +- `from`: فیلتر از این تاریخ به بعد +- `to`: فیلتر تا این تاریخ +- `zone`: فیلتر بر اساس zone +- `search`: جستجو در `title` و `note` +- `farm_uuid`: محدود کردن نتایج به یک مزرعه + +#### Behavior + +- فقط taskهای متعلق به farmهای کاربر login شده را برمی‌گرداند +- `priority` ورودی مثل `high` به مقدار داخلی مثل `زیاد` normalize می‌شود +- `search` در `title` و `description` مدل جستجو می‌کند +- خروجی بر اساس `scheduled_date` و `time` و `created_at` مرتب می‌شود + +#### Sample Request + +```http +GET /api/farmer-todos/?farm_uuid=&priority=high&status=open&search=رطوبت +``` + +#### Sample Response + +```json +{ + "tasks": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 2) Create Task + +```http +POST /api/farmer-todos/ +``` + +#### Request Body + +- `title`: اجباری، string +- `zone`: اجباری، string +- `scheduledDate`: اجباری، date با فرمت `YYYY-MM-DD` +- `time`: اجباری، time با فرمت `HH:MM` +- `priority`: اجباری +- `note`: اختیاری، string +- `tags`: اختیاری، array از tagهای enum +- `status`: اختیاری، `open` یا `done` +- `farm_uuid`: اختیاری، اما اگر کاربر چند farm داشته باشد اجباری می‌شود + +#### Validation Rules + +- `title` نباید خالی باشد +- `zone` نباید خالی باشد +- `priority` باید از enum مجاز باشد +- `tags` باید فقط از enum مجاز باشند +- در create اگر fieldهای اصلی نباشند خطای validation برمی‌گردد + +fieldهای اجباری: + +- `title` +- `zone` +- `scheduledDate` +- `time` +- `priority` + +#### Sample Request + +```json +{ + "farm_uuid": "6b7ce8a8-13ec-4a6e-9118-7c298fd2a111", + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "medium", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open" +} +``` + +#### Sample Success Response + +```json +{ + "task": { + "id": "7aa97f9f-bc4c-49f1-858f-11f3f433a111", + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "متوسط", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open" + } +} +``` + +#### Sample Validation Error + +```json +{ + "code": "TASK_VALIDATION_ERROR", + "message": "priority must be one of زیاد, متوسط, کم, high, medium, low", + "details": { + "priority": [ + "priority must be one of زیاد, متوسط, کم, high, medium, low" + ] + } +} +``` + +--- + +### 3) Get Task Detail + +```http +GET /api/farmer-todos// +``` + +#### Path Param + +- `task_uuid`: شناسه UUID تسک + +#### Behavior + +- فقط اگر task متعلق به کاربر باشد برگردانده می‌شود +- اگر وجود نداشته باشد یا متعلق به کاربر دیگری باشد، `404` می‌دهد + +#### Sample Response + +```json +{ + "task": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } +} +``` + +#### Sample Not Found Response + +```json +{ + "code": "TASK_NOT_FOUND", + "message": "Task not found." +} +``` + +--- + +### 4) Update Task + +```http +PUT /api/farmer-todos// +``` + +#### Behavior + +- این endpoint از `partial=True` استفاده می‌کند، پس می‌توانی فقط بخشی از فیلدها را بفرستی +- اگر `farm_uuid` ارسال شود، نباید farm فعلی task را تغییر دهد +- اگر `tags` ارسال شوند، لیست tagهای task با لیست جدید جایگزین می‌شود +- اگر `zone` ارسال شود، zone جدید resolve یا ساخته می‌شود + +#### Sample Request + +```json +{ + "status": "done" +} +``` + +یا: + +```json +{ + "priority": "high", + "tags": ["فوری", "بازدید"], + "note": "این کار باید امروز نهایی شود." +} +``` + +#### Sample Response + +```json +{ + "task": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "done" + } +} +``` + +--- + +### 5) Delete Task + +```http +DELETE /api/farmer-todos// +``` + +#### Sample Response + +```json +{ + "success": true +} +``` + +--- + +### 6) List Zones + +```http +GET /api/farmer-todos/zones/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری + +#### Behavior + +- zoneها از دیتابیس خوانده می‌شوند +- اگر `farm_uuid` ارسال شود، zoneها به همان farm محدود می‌شوند +- اگر `farm_uuid` ارسال نشود، zoneهای تکراری بین farmهای کاربر deduplicate می‌شوند + +#### Sample Response + +```json +{ + "zones": [ + { + "id": "zone_gndm-shmal-mzrh", + "label": "قطعه گندم - شمال مزرعه", + "value": "قطعه گندم - شمال مزرعه" + } + ], + "meta": { + "total": 1 + } +} +``` + +--- + +### 7) List Tags + +```http +GET /api/farmer-todos/tags/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری؛ در نسخه فعلی فقط validate می‌شود + +#### Behavior + +- tagها از enum داخلی کد برمی‌گردند +- این endpoint دیگر از جدول tag چیزی نمی‌خواند + +#### Sample Response + +```json +{ + "tags": [ + { + "id": "tag_irrigation", + "label": "آبیاری", + "value": "آبیاری" + }, + { + "id": "tag_pest", + "label": "آفت", + "value": "آفت" + }, + { + "id": "tag_urgent", + "label": "فوری", + "value": "فوری" + } + ], + "meta": { + "total": 9 + } +} +``` + +--- + +### 8) Summary + +```http +GET /api/farmer-todos/summary/ +``` + +#### Query Params + +- `farm_uuid`: اختیاری + +#### Response Fields + +- `todayTasksCount`: تعداد taskهای امروز +- `completedCount`: تعداد taskهای انجام‌شده +- `urgentCount`: تعداد taskهای باز با priority بالا +- `progressValue`: درصد پیشرفت +- `nextTask`: نزدیک‌ترین task باز + +#### Behavior + +- `progressValue` از نسبت `completedCount / totalCount` محاسبه می‌شود +- `nextTask` اولین task باز از امروز به بعد است + +#### Sample Response + +```json +{ + "todayTasksCount": 2, + "completedCount": 1, + "urgentCount": 2, + "progressValue": 50, + "nextTask": { + "id": "11111111-1111-1111-1111-111111111111", + "title": "بررسی رطوبت ردیف شمالی", + "zone": "قطعه گندم - شمال مزرعه", + "scheduledDate": "2025-02-24", + "time": "06:30", + "priority": "زیاد", + "note": "اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + "tags": ["آبیاری"], + "status": "open" + } +} +``` + +## Error Format + +خطاهای validation: + +```json +{ + "code": "TASK_VALIDATION_ERROR", + "message": "error message", + "details": {} +} +``` + +خطای پیدا نشدن: + +```json +{ + "code": "TASK_NOT_FOUND", + "message": "Task not found." +} +``` + +## Farm Resolution Rules + +رفتار `farm_uuid`: + +- اگر کاربر فقط یک farm داشته باشد، در create می‌تواند `farm_uuid` نفرستد +- اگر کاربر چند farm داشته باشد، در create باید `farm_uuid` بفرستد +- اگر `farm_uuid` نامعتبر باشد، validation error برمی‌گردد +- در update، `farm_uuid` نباید farm task را تغییر دهد + +## Notes + +- فایل routeها: `farmer_todos/urls.py` +- فایل viewها: `farmer_todos/views.py` +- فایل serializerها: `farmer_todos/serializers.py` +- enumهای مشترک: `farmer_calendar/enums.py` + +## Related Note + +در ساختار فعلی پروژه، `farmer_todos` از مدل مشترک با `farmer_calendar` استفاده می‌کند، ولی response و endpointهای این فایل مخصوص todo هستند. diff --git a/Modules/Backend/farmer_todos/__init__.py b/Modules/Backend/farmer_todos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farmer_todos/apps.py b/Modules/Backend/farmer_todos/apps.py new file mode 100644 index 0000000..0a2439d --- /dev/null +++ b/Modules/Backend/farmer_todos/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FarmerTodosConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "farmer_todos" + verbose_name = "Farmer Todos" diff --git a/Modules/Backend/farmer_todos/migrations/0001_initial.py b/Modules/Backend/farmer_todos/migrations/0001_initial.py new file mode 100644 index 0000000..58ad51c --- /dev/null +++ b/Modules/Backend/farmer_todos/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.5 on 2025-02-24 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0009_farmhub_irrigation_method_fields"), + ] + + operations = [ + migrations.CreateModel( + name="FarmerTodoZone", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=255)), + ("value", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_zones", to="farm_hub.farmhub"), + ), + ], + options={"db_table": "farmer_todo_zones", "ordering": ["label"]}, + ), + migrations.CreateModel( + name="FarmerTodoTag", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("label", models.CharField(max_length=100)), + ("value", models.CharField(max_length=100)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_tags", to="farm_hub.farmhub"), + ), + ], + options={"db_table": "farmer_todo_tags", "ordering": ["label"]}, + ), + migrations.CreateModel( + name="FarmerTodoTask", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=255)), + ("scheduled_date", models.DateField()), + ("time", models.TimeField()), + ("priority", models.CharField(choices=[("زیاد", "High"), ("متوسط", "Medium"), ("کم", "Low")], max_length=16)), + ("note", models.TextField(blank=True, default="")), + ("status", models.CharField(choices=[("open", "Open"), ("done", "Done")], default="open", max_length=16)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="todo_tasks", to="farm_hub.farmhub"), + ), + ( + "tags", + models.ManyToManyField(blank=True, related_name="tasks", to="farmer_todos.farmertodotag"), + ), + ( + "zone", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="tasks", to="farmer_todos.farmertodozone"), + ), + ], + options={"db_table": "farmer_todo_tasks", "ordering": ["scheduled_date", "time", "created_at"]}, + ), + migrations.AddConstraint( + model_name="farmertodozone", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_todo_zone_per_farm"), + ), + migrations.AddConstraint( + model_name="farmertodotag", + constraint=models.UniqueConstraint(fields=("farm", "value"), name="uniq_farmer_todo_tag_per_farm"), + ), + ] diff --git a/Modules/Backend/farmer_todos/migrations/0002_merge_todos_into_calendar.py b/Modules/Backend/farmer_todos/migrations/0002_merge_todos_into_calendar.py new file mode 100644 index 0000000..169d8b6 --- /dev/null +++ b/Modules/Backend/farmer_todos/migrations/0002_merge_todos_into_calendar.py @@ -0,0 +1,104 @@ +from django.db import migrations + + +def forwards(apps, schema_editor): + TodoZone = apps.get_model("farmer_todos", "FarmerTodoZone") + TodoTag = apps.get_model("farmer_todos", "FarmerTodoTag") + TodoTask = apps.get_model("farmer_todos", "FarmerTodoTask") + CalendarZone = apps.get_model("farmer_calendar", "FarmerCalendarZone") + CalendarTag = apps.get_model("farmer_calendar", "FarmerCalendarTag") + CalendarEvent = apps.get_model("farmer_calendar", "FarmerCalendarEvent") + + zone_map = {} + for todo_zone in TodoZone.objects.all().iterator(): + calendar_zone, _ = CalendarZone.objects.get_or_create( + farm_id=todo_zone.farm_id, + value=todo_zone.value, + defaults={ + "uuid": todo_zone.uuid, + "label": todo_zone.label, + "is_active": todo_zone.is_active, + "created_at": todo_zone.created_at, + "updated_at": todo_zone.updated_at, + }, + ) + updated = False + if calendar_zone.label != todo_zone.label: + calendar_zone.label = todo_zone.label + updated = True + if calendar_zone.is_active != todo_zone.is_active: + calendar_zone.is_active = todo_zone.is_active + updated = True + if updated: + calendar_zone.save(update_fields=["label", "is_active", "updated_at"]) + zone_map[todo_zone.id] = calendar_zone + + tag_map = {} + for todo_tag in TodoTag.objects.all().iterator(): + calendar_tag, _ = CalendarTag.objects.get_or_create( + farm_id=todo_tag.farm_id, + value=todo_tag.value, + defaults={ + "uuid": todo_tag.uuid, + "label": todo_tag.label, + "is_active": todo_tag.is_active, + "created_at": todo_tag.created_at, + "updated_at": todo_tag.updated_at, + }, + ) + updated = False + if calendar_tag.label != todo_tag.label: + calendar_tag.label = todo_tag.label + updated = True + if calendar_tag.is_active != todo_tag.is_active: + calendar_tag.is_active = todo_tag.is_active + updated = True + if updated: + calendar_tag.save(update_fields=["label", "is_active", "updated_at"]) + tag_map[todo_tag.id] = calendar_tag + + through_model = TodoTask.tags.through + task_tags = {} + for relation in through_model.objects.all().iterator(): + task_tags.setdefault(relation.farmertodotask_id, []).append(relation.farmertodotag_id) + + for todo_task in TodoTask.objects.all().iterator(): + calendar_event, created = CalendarEvent.objects.get_or_create( + farm_id=todo_task.farm_id, + title=todo_task.title, + scheduled_date=todo_task.scheduled_date, + time=todo_task.time, + defaults={ + "zone": zone_map.get(todo_task.zone_id), + "description": todo_task.note, + "priority": todo_task.priority, + "status": todo_task.status, + "created_at": todo_task.created_at, + "updated_at": todo_task.updated_at, + }, + ) + if not created: + calendar_event.zone = zone_map.get(todo_task.zone_id) + calendar_event.description = todo_task.note + calendar_event.priority = todo_task.priority + calendar_event.status = todo_task.status + calendar_event.save(update_fields=["zone", "description", "priority", "status", "updated_at"]) + + calendar_tags = [tag_map[tag_id] for tag_id in task_tags.get(todo_task.id, []) if tag_id in tag_map] + if calendar_tags: + calendar_event.tags.set(calendar_tags) + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("farmer_calendar", "0001_initial"), + ("farmer_todos", "0001_initial"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/Modules/Backend/farmer_todos/migrations/__init__.py b/Modules/Backend/farmer_todos/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/farmer_todos/models.py b/Modules/Backend/farmer_todos/models.py new file mode 100644 index 0000000..7cae9bd --- /dev/null +++ b/Modules/Backend/farmer_todos/models.py @@ -0,0 +1,10 @@ +from farmer_calendar.models import ( + FarmerCalendarEvent, + FarmerCalendarTag, + FarmerCalendarZone, +) + + +FarmerTodoZone = FarmerCalendarZone +FarmerTodoTag = FarmerCalendarTag +FarmerTodoTask = FarmerCalendarEvent diff --git a/Modules/Backend/farmer_todos/serializers.py b/Modules/Backend/farmer_todos/serializers.py new file mode 100644 index 0000000..167409b --- /dev/null +++ b/Modules/Backend/farmer_todos/serializers.py @@ -0,0 +1,178 @@ +from rest_framework import serializers + +from farmer_calendar.enums import FARMER_TAG_VALUES, PRIORITY_INPUT_MAP +from farmer_calendar.models import FarmerCalendarZone + +from .models import FarmerTodoTask + + +class FarmerTodoTaskResponseSerializer(serializers.ModelSerializer): + id = serializers.UUIDField(source="uuid", read_only=True) + zone = serializers.CharField(source="zone.value", read_only=True, allow_null=True) + scheduledDate = serializers.DateField(source="scheduled_date", format="%Y-%m-%d", read_only=True) + time = serializers.TimeField(format="%H:%M", read_only=True) + note = serializers.CharField(source="description", read_only=True) + tags = serializers.SerializerMethodField() + + class Meta: + model = FarmerTodoTask + fields = [ + "id", + "title", + "zone", + "scheduledDate", + "time", + "priority", + "note", + "tags", + "status", + ] + + def get_tags(self, obj): + raw_tags = obj.extended_props.get("tags", []) + return [tag for tag in raw_tags if tag in FARMER_TAG_VALUES] + + +class FarmerTodoChoiceSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + value = serializers.CharField() + + +class FarmerTodoZoneSerializer(FarmerTodoChoiceSerializer): + prefix = "zone_" + + +class FarmerTodoTagSerializer(FarmerTodoChoiceSerializer): + pass + + +class FarmerTodoTaskWriteSerializer(serializers.Serializer): + title = serializers.CharField(max_length=255, required=False) + zone = serializers.CharField(max_length=255, required=False) + scheduledDate = serializers.DateField(required=False, format="%Y-%m-%d", input_formats=["%Y-%m-%d"]) + time = serializers.TimeField(required=False, format="%H:%M", input_formats=["%H:%M"]) + priority = serializers.CharField(required=False) + note = serializers.CharField(required=False, allow_blank=True, default="") + tags = serializers.ListField( + child=serializers.CharField(max_length=100), + required=False, + default=list, + allow_empty=True, + ) + status = serializers.ChoiceField(choices=[FarmerTodoTask.STATUS_OPEN, FarmerTodoTask.STATUS_DONE], required=False) + farm_uuid = serializers.UUIDField(required=False, write_only=True) + + def validate_title(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("title cannot be empty") + return value + + def validate_zone(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("zone cannot be empty") + return value + + def validate_priority(self, value): + normalized = PRIORITY_INPUT_MAP.get(value.strip().lower(), PRIORITY_INPUT_MAP.get(value.strip())) + if normalized is None: + raise serializers.ValidationError("priority must be one of زیاد, متوسط, کم, high, medium, low") + return normalized + + def validate_tags(self, value): + normalized = [] + for tag in value: + cleaned = tag.strip() + if cleaned: + if cleaned not in FARMER_TAG_VALUES: + raise serializers.ValidationError(f"tag `{cleaned}` is not valid") + normalized.append(cleaned) + return normalized + + def validate(self, attrs): + if not self.partial: + required_fields = ["title", "zone", "scheduledDate", "time", "priority"] + errors = {} + for field in required_fields: + if field not in attrs: + errors[field] = [f"{field} is required"] + if errors: + raise serializers.ValidationError(errors) + return attrs + + @staticmethod + def _sync_zone(task, zone_value): + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=task.farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + task.zone = zone + + def create(self, validated_data): + zone_value = validated_data.pop("zone") + tags = validated_data.pop("tags", []) + validated_data.pop("farm_uuid", None) + validated_data["scheduled_date"] = validated_data.pop("scheduledDate") + validated_data["description"] = validated_data.pop("note", "") + validated_data["extended_props"] = {"tags": tags} + farm = validated_data["farm"] + zone, _ = FarmerCalendarZone.objects.get_or_create( + farm=farm, + value=zone_value, + defaults={"label": zone_value}, + ) + if zone.label != zone_value: + zone.label = zone_value + zone.save(update_fields=["label", "updated_at"]) + task = FarmerTodoTask.objects.create(zone=zone, **validated_data) + return task + + def update(self, instance, validated_data): + zone_value = validated_data.pop("zone", None) + tags = validated_data.pop("tags", None) + validated_data.pop("farm_uuid", None) + if "scheduledDate" in validated_data: + validated_data["scheduled_date"] = validated_data.pop("scheduledDate") + if "note" in validated_data: + validated_data["description"] = validated_data.pop("note") + if tags is not None: + extended_props = dict(instance.extended_props or {}) + extended_props["tags"] = tags + validated_data["extended_props"] = extended_props + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + if zone_value is not None: + self._sync_zone(instance, zone_value) + instance.save() + return instance + + +class FarmerTodoListQuerySerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=[FarmerTodoTask.STATUS_OPEN, FarmerTodoTask.STATUS_DONE], required=False) + priority = serializers.CharField(required=False) + date = serializers.DateField(required=False, input_formats=["%Y-%m-%d"]) + from_date = serializers.DateField(required=False, input_formats=["%Y-%m-%d"], source="from") + to = serializers.DateField(required=False, input_formats=["%Y-%m-%d"]) + zone = serializers.CharField(required=False) + search = serializers.CharField(required=False) + farm_uuid = serializers.UUIDField(required=False) + + def validate_priority(self, value): + normalized = PRIORITY_INPUT_MAP.get(value.strip().lower(), PRIORITY_INPUT_MAP.get(value.strip())) + if normalized is None: + raise serializers.ValidationError("priority must be one of زیاد, متوسط, کم, high, medium, low") + return normalized + + def validate(self, attrs): + from_date = attrs.get("from") + to_date = attrs.get("to") + if from_date and to_date and to_date < from_date: + raise serializers.ValidationError({"to": "to cannot be before from"}) + return attrs diff --git a/Modules/Backend/farmer_todos/tests.py b/Modules/Backend/farmer_todos/tests.py new file mode 100644 index 0000000..7785392 --- /dev/null +++ b/Modules/Backend/farmer_todos/tests.py @@ -0,0 +1,238 @@ +from datetime import date, time, timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from access_control.models import SubscriptionPlan +from farm_hub.models import FarmHub, FarmType + +from .models import FarmerTodoTask, FarmerTodoZone +from .views import ( + FarmerTodoDetailView, + FarmerTodoListCreateView, + FarmerTodoSummaryView, + FarmerTodoTagsView, + FarmerTodoZonesView, +) + + +class FarmerTodoViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="todo-user", + password="secret123", + email="todo@example.com", + phone_number="09123333333", + ) + self.other_user = get_user_model().objects.create_user( + username="todo-other", + password="secret123", + email="todo-other@example.com", + phone_number="09124444444", + ) + self.plan = SubscriptionPlan.objects.create(code="todo-plan", name="Todo Plan") + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Farm A", + ) + self.other_farm = FarmHub.objects.create( + owner=self.other_user, + farm_type=self.farm_type, + subscription_plan=self.plan, + name="Farm B", + ) + self.zone = FarmerTodoZone.objects.create( + farm=self.farm, + label="قطعه گندم - شمال مزرعه", + value="قطعه گندم - شمال مزرعه", + ) + self.task = FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + uuid="11111111-1111-1111-1111-111111111111", + title="بررسی رطوبت ردیف شمالی", + scheduled_date=date.today(), + time=time(6, 30), + priority=FarmerTodoTask.PRIORITY_HIGH, + description="اگر رطوبت کمتر از 28٪ بود، آبیاری دوباره بررسی شود.", + status=FarmerTodoTask.STATUS_OPEN, + extended_props={"tags": ["آبیاری"]}, + ) + + def test_list_tasks_returns_expected_shape(self): + request = self.factory.get(f"/api/farmer-todos/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tasks"][0]["zone"], self.zone.value) + self.assertEqual(response.data["tasks"][0]["priority"], "زیاد") + self.assertEqual(response.data["tasks"][0]["time"], "06:30") + self.assertEqual(str(response.data["tasks"][0]["id"]), str(self.task.uuid)) + + def test_create_task_creates_zone_and_tags(self): + request = self.factory.post( + "/api/farmer-todos/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "بازدید پمپ جنوب", + "zone": "انبار مرکزی", + "scheduledDate": "2025-02-24", + "time": "07:00", + "priority": "medium", + "note": "بعد از ثبت انجام، مورد غیرعادی را یادداشت کن.", + "tags": ["روزانه", "ثبت دستی"], + "status": "open", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["task"]["priority"], "متوسط") + self.assertEqual(response.data["task"]["zone"], "انبار مرکزی") + self.assertEqual(response.data["task"]["tags"], ["روزانه", "ثبت دستی"]) + self.assertTrue(FarmerTodoZone.objects.filter(farm=self.farm, value="انبار مرکزی").exists()) + + def test_update_task_supports_status_only_payload(self): + request = self.factory.put( + f"/api/farmer-todos/{self.task.uuid}/", + {"status": "done"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=self.task.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["task"]["status"], "done") + + def test_filter_by_search_and_priority(self): + FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="نمونه برداری خاک", + scheduled_date=date(2025, 2, 25), + time=time(9, 15), + priority=FarmerTodoTask.PRIORITY_LOW, + description="سه نقطه برداشت شود.", + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get( + f"/api/farmer-todos/?farm_uuid={self.farm.farm_uuid}&priority=high&search=رطوبت" + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["total"], 1) + self.assertEqual(response.data["tasks"][0]["title"], "بررسی رطوبت ردیف شمالی") + + def test_zones_and_tags_endpoints_return_separate_lists(self): + zone_request = self.factory.get(f"/api/farmer-todos/zones/?farm_uuid={self.farm.farm_uuid}") + tag_request = self.factory.get(f"/api/farmer-todos/tags/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(zone_request, user=self.user) + force_authenticate(tag_request, user=self.user) + + zone_response = FarmerTodoZonesView.as_view()(zone_request) + tag_response = FarmerTodoTagsView.as_view()(tag_request) + + self.assertEqual(zone_response.status_code, 200) + self.assertEqual(tag_response.status_code, 200) + self.assertEqual(zone_response.data["zones"][0]["value"], self.zone.value) + self.assertTrue(any(item["value"] == "آبیاری" for item in tag_response.data["tags"])) + + def test_summary_returns_expected_counts(self): + FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="کار انجام شده", + scheduled_date=date.today(), + time=time(8, 0), + priority=FarmerTodoTask.PRIORITY_MEDIUM, + description="", + status=FarmerTodoTask.STATUS_DONE, + ) + upcoming = FarmerTodoTask.objects.create( + farm=self.farm, + zone=self.zone, + title="کار بعدی", + scheduled_date=date.today() + timedelta(days=1), + time=time(7, 0), + priority=FarmerTodoTask.PRIORITY_HIGH, + description="", + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get(f"/api/farmer-todos/summary/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FarmerTodoSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["completedCount"], 1) + self.assertEqual(response.data["urgentCount"], 2) + self.assertEqual(str(response.data["nextTask"]["id"]), str(self.task.uuid)) + self.assertNotEqual(str(response.data["nextTask"]["id"]), str(upcoming.uuid)) + + def test_delete_task_returns_success(self): + request = self.factory.delete(f"/api/farmer-todos/{self.task.uuid}/") + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=self.task.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"success": True}) + self.assertFalse(FarmerTodoTask.objects.filter(pk=self.task.pk).exists()) + + def test_validation_error_returns_message_and_details(self): + request = self.factory.post( + "/api/farmer-todos/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "", + "priority": "unknown", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FarmerTodoListCreateView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], "TASK_VALIDATION_ERROR") + self.assertIn("message", response.data) + self.assertIn("details", response.data) + + def test_detail_rejects_foreign_task(self): + foreign_zone = FarmerTodoZone.objects.create( + farm=self.other_farm, + label="foreign zone", + value="foreign zone", + ) + foreign_task = FarmerTodoTask.objects.create( + farm=self.other_farm, + zone=foreign_zone, + uuid="22222222-2222-2222-2222-222222222222", + title="foreign task", + scheduled_date=date(2025, 2, 24), + time=time(6, 30), + priority=FarmerTodoTask.PRIORITY_HIGH, + status=FarmerTodoTask.STATUS_OPEN, + ) + request = self.factory.get(f"/api/farmer-todos/{foreign_task.uuid}/") + force_authenticate(request, user=self.user) + + response = FarmerTodoDetailView.as_view()(request, task_id=foreign_task.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["message"], "Task not found.") diff --git a/Modules/Backend/farmer_todos/urls.py b/Modules/Backend/farmer_todos/urls.py new file mode 100644 index 0000000..4717987 --- /dev/null +++ b/Modules/Backend/farmer_todos/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + FarmerTodoDetailView, + FarmerTodoListCreateView, + FarmerTodoSummaryView, + FarmerTodoTagsView, + FarmerTodoZonesView, +) + +urlpatterns = [ + path("zones/", FarmerTodoZonesView.as_view(), name="farmer-todo-zones"), + path("tags/", FarmerTodoTagsView.as_view(), name="farmer-todo-tags"), + path("summary/", FarmerTodoSummaryView.as_view(), name="farmer-todo-summary"), + path("/", FarmerTodoDetailView.as_view(), name="farmer-todo-detail"), + path("", FarmerTodoListCreateView.as_view(), name="farmer-todo-list-create"), +] diff --git a/Modules/Backend/farmer_todos/views.py b/Modules/Backend/farmer_todos/views.py new file mode 100644 index 0000000..9653c1b --- /dev/null +++ b/Modules/Backend/farmer_todos/views.py @@ -0,0 +1,242 @@ +from datetime import date + +from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import serializers, status +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from farm_hub.models import FarmHub +from farmer_calendar.enums import FARMER_TAG_ITEMS +from farmer_calendar.models import FarmerCalendarZone + +from .models import FarmerTodoTask +from .serializers import ( + FarmerTodoListQuerySerializer, + FarmerTodoTagSerializer, + FarmerTodoTaskResponseSerializer, + FarmerTodoTaskWriteSerializer, + FarmerTodoZoneSerializer, +) + + +class FarmerTodoBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def error_response(message, details=None, code="TASK_VALIDATION_ERROR", status_code=status.HTTP_400_BAD_REQUEST): + payload = {"code": code, "message": message} + if details is not None: + payload["details"] = details + return Response(payload, status=status_code) + + def handle_exception(self, exc): + if isinstance(exc, serializers.ValidationError): + details = exc.detail + message = "Invalid farmer todo payload" + if isinstance(details, dict): + first_value = next(iter(details.values()), None) + if isinstance(first_value, list) and first_value: + message = str(first_value[0]) + elif first_value: + message = str(first_value) + elif isinstance(details, list) and details: + message = str(details[0]) + return self.error_response(message=message, details=details) + if isinstance(exc, NotFound): + return self.error_response( + message=str(exc.detail), + code="TASK_NOT_FOUND", + status_code=status.HTTP_404_NOT_FOUND, + ) + return super().handle_exception(exc) + + def _get_user_farms(self, request): + return FarmHub.objects.filter(owner=request.user).order_by("id") + + def _resolve_farm(self, request, farm_uuid=None, required=False): + farms = self._get_user_farms(request) + if farm_uuid: + try: + return farms.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + if required: + farm_count = farms.count() + if farm_count == 1: + return farms.first() + if farm_count == 0: + raise serializers.ValidationError({"farm_uuid": ["No farm found for this user."]}) + raise serializers.ValidationError({"farm_uuid": ["farm_uuid is required when multiple farms exist."]}) + return None + + def _get_task(self, request, task_id): + queryset = FarmerTodoTask.objects.select_related("farm", "zone").prefetch_related("tags") + try: + return queryset.get(uuid=task_id, farm__owner=request.user) + except FarmerTodoTask.DoesNotExist as exc: + raise NotFound("Task not found.") from exc + + +class FarmerTodoListCreateView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="status", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="priority", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="date", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="from", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="to", type=OpenApiTypes.DATE, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="zone", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="search", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False), + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + query_data = request.query_params.copy() + if "from" in query_data and "from_date" not in query_data: + query_data["from_date"] = query_data["from"] + query_serializer = FarmerTodoListQuerySerializer(data=query_data) + query_serializer.is_valid(raise_exception=True) + filters = query_serializer.validated_data + + queryset = FarmerTodoTask.objects.filter(farm__owner=request.user).select_related("zone").prefetch_related("tags") + farm = self._resolve_farm(request, filters.get("farm_uuid"), required=False) + if farm is not None: + queryset = queryset.filter(farm=farm) + if filters.get("status"): + queryset = queryset.filter(status=filters["status"]) + if filters.get("priority"): + queryset = queryset.filter(priority=filters["priority"]) + if filters.get("date"): + queryset = queryset.filter(scheduled_date=filters["date"]) + if filters.get("from"): + queryset = queryset.filter(scheduled_date__gte=filters["from"]) + if filters.get("to"): + queryset = queryset.filter(scheduled_date__lte=filters["to"]) + if filters.get("zone"): + queryset = queryset.filter(zone__value=filters["zone"].strip()) + if filters.get("search"): + search_value = filters["search"].strip() + queryset = queryset.filter(Q(title__icontains=search_value) | Q(description__icontains=search_value)) + + tasks = queryset.order_by("scheduled_date", "time", "created_at") + data = FarmerTodoTaskResponseSerializer(tasks, many=True).data + return Response({"tasks": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def post(self, request): + serializer = FarmerTodoTaskWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + farm = self._resolve_farm(request, serializer.validated_data.get("farm_uuid"), required=True) + task = serializer.save(farm=farm) + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_201_CREATED) + + +class FarmerTodoDetailView(FarmerTodoBaseView): + @extend_schema(tags=["Farmer Todos"]) + def put(self, request, task_id): + task = self._get_task(request, task_id) + serializer = FarmerTodoTaskWriteSerializer(task, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + requested_farm_uuid = serializer.validated_data.get("farm_uuid") + if requested_farm_uuid and str(task.farm.farm_uuid) != str(requested_farm_uuid): + return self.error_response( + message="farm_uuid cannot change an existing task", + details={"farm_uuid": ["farm_uuid cannot change an existing task"]}, + ) + + task = serializer.save() + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def delete(self, request, task_id): + task = self._get_task(request, task_id) + task.delete() + return Response({"success": True}, status=status.HTTP_200_OK) + + @extend_schema(tags=["Farmer Todos"]) + def get(self, request, task_id): + task = self._get_task(request, task_id) + data = FarmerTodoTaskResponseSerializer(task).data + return Response({"task": data}, status=status.HTTP_200_OK) + + +class FarmerTodoZonesView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + farm = self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + queryset = FarmerCalendarZone.objects.filter(farm__owner=request.user, is_active=True) + if farm is not None: + queryset = queryset.filter(farm=farm) + if farm is None: + unique_zones = {} + for zone in queryset.order_by("label", "created_at"): + unique_zones.setdefault(zone.value, zone) + zones = list(unique_zones.values()) + else: + zones = queryset.order_by("label") + data = FarmerTodoZoneSerializer(zones, many=True).data + return Response({"zones": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class FarmerTodoTagsView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + data = FarmerTodoTagSerializer(FARMER_TAG_ITEMS, many=True).data + return Response({"tags": data, "meta": {"total": len(data)}}, status=status.HTTP_200_OK) + + +class FarmerTodoSummaryView(FarmerTodoBaseView): + @extend_schema( + tags=["Farmer Todos"], + parameters=[ + OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False), + ], + ) + def get(self, request): + farm = self._resolve_farm(request, request.query_params.get("farm_uuid"), required=False) + queryset = FarmerTodoTask.objects.filter(farm__owner=request.user).select_related("zone").prefetch_related("tags") + if farm is not None: + queryset = queryset.filter(farm=farm) + + today = date.today() + total_count = queryset.count() + today_count = queryset.filter(scheduled_date=today).count() + completed_count = queryset.filter(status=FarmerTodoTask.STATUS_DONE).count() + urgent_count = queryset.filter(priority=FarmerTodoTask.PRIORITY_HIGH, status=FarmerTodoTask.STATUS_OPEN).count() + next_task = queryset.filter( + status=FarmerTodoTask.STATUS_OPEN, + ).filter( + Q(scheduled_date__gt=today) | Q(scheduled_date=today) + ).order_by("scheduled_date", "time", "created_at").first() + + progress_value = int((completed_count / total_count) * 100) if total_count else 0 + next_task_data = FarmerTodoTaskResponseSerializer(next_task).data if next_task else None + return Response( + { + "todayTasksCount": today_count, + "completedCount": completed_count, + "urgentCount": urgent_count, + "progressValue": progress_value, + "nextTask": next_task_data, + }, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/fertilization/FERTILIZATION_PLAN_APIS.md b/Modules/Backend/fertilization/FERTILIZATION_PLAN_APIS.md new file mode 100644 index 0000000..3894542 --- /dev/null +++ b/Modules/Backend/fertilization/FERTILIZATION_PLAN_APIS.md @@ -0,0 +1,235 @@ +# Fertilization Plan APIs + +این فایل APIهای مدیریت برنامه‌های کودی را توضیح می‌دهد. + +Base path: + +`/api/fertilization/` + +این APIها فقط روی برنامه‌های متعلق به کاربر لاگین‌شده عمل می‌کنند. + +--- + +## 1) دریافت لیست برنامه‌های کودی + +### Request + +- Method: `GET` +- URL: `/api/fertilization/plans/` +- Query params: + - `farm_uuid` الزامی + - `page` اختیاری + - `page_size` اختیاری، حداکثر `100` + +### Example + +```http +GET /api/fertilization/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10 +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه کودی گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": false, + "created_at": "2025-02-24T10:20:30Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 1, + "total_items": 1, + "has_next": false, + "has_previous": false, + "next": null, + "previous": null + } +} +``` + +### Notes + +- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند. +- ترتیب لیست از جدید به قدیم است. +- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد. + +--- + +## 2) دریافت جزئیات یک برنامه کودی + +### Request + +- Method: `GET` +- URL: `/api/fertilization/plans/{plan_uuid}/` +- Path param: + - `plan_uuid` الزامی + +### Example + +```http +GET /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه کودی گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": false, + "created_at": "2025-02-24T10:20:30Z", + "updated_at": "2025-02-24T10:20:30Z", + "plan_payload": { + "title": "برنامه کودی گندم", + "items": [ + { + "name": "NPK 20-20-20" + } + ] + } + } +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +### Notes + +- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده می‌شود. + +--- + +## 3) حذف برنامه کودی + +### Request + +- Method: `DELETE` +- URL: `/api/fertilization/plans/{plan_uuid}/` + +### Example + +```http +DELETE /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_deleted": true + } +} +``` + +### Behavior + +- حذف به‌صورت `soft delete` انجام می‌شود. +- در عمل: + - `is_deleted = true` + - `is_active = false` + - `deleted_at` مقداردهی می‌شود + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## 4) تغییر وضعیت فعال بودن برنامه کودی + +### Request + +- Method: `PATCH` +- URL: `/api/fertilization/plans/{plan_uuid}/status/` +- Body: + - `is_active` الزامی، `boolean` + +### Example + +```http +PATCH /api/fertilization/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/ +Content-Type: application/json + +{ + "is_active": false +} +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_active": true + } +} +``` + +### Validation Error + +```json +{ + "is_active": [ + "This field is required." + ] +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## Summary + +- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند. +- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد. +- `GET /api/fertilization/plans/` لیست برنامه‌ها +- `GET /api/fertilization/plans/{plan_uuid}/` جزئیات برنامه +- `DELETE /api/fertilization/plans/{plan_uuid}/` حذف نرم برنامه +- `PATCH /api/fertilization/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه diff --git a/Modules/Backend/fertilization/__init__.py b/Modules/Backend/fertilization/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/fertilization/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/fertilization/apps.py b/Modules/Backend/fertilization/apps.py new file mode 100644 index 0000000..4245dc8 --- /dev/null +++ b/Modules/Backend/fertilization/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class FertilizationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "fertilization" + label = "fertilization_recommendation" + verbose_name = "Fertilization Recommendation & Plan Parser" diff --git a/Modules/Backend/fertilization/defaults.py b/Modules/Backend/fertilization/defaults.py new file mode 100644 index 0000000..138e1fb --- /dev/null +++ b/Modules/Backend/fertilization/defaults.py @@ -0,0 +1,35 @@ +CONFIG_RESPONSE_TEMPLATE = { + "farmData": { + "soilType": None, + "organicMatter": None, + "waterEC": None, + }, + "growthStages": [ + {"id": "prePlanting", "icon": "tabler-seedling"}, + {"id": "earlyGrowth", "icon": "tabler-leaf"}, + {"id": "flowering", "icon": "tabler-flower"}, + {"id": "fruiting", "icon": "tabler-apple"}, + {"id": "postHarvest", "icon": "tabler-basket"}, + ], + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], + "status": "success", + "source": "default_template", +} + + +FERTILIZATION_DASHBOARD_TEMPLATE = { + "title": "کود", + "subtitle": "داده توصیه کودهی هنوز ثبت نشده است.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", + "status": "empty", + "source": "db", + "warnings": ["No persisted fertilization recommendation is available for this farm."], +} diff --git a/Modules/Backend/fertilization/migrations/0001_initial.py b/Modules/Backend/fertilization/migrations/0001_initial.py new file mode 100644 index 0000000..6cfc396 --- /dev/null +++ b/Modules/Backend/fertilization/migrations/0001_initial.py @@ -0,0 +1,41 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FertilizationRecommendationRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)), + ("status", models.CharField(blank=True, default="", max_length=64)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fertilizations", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "fertilization_requests", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/fertilization/migrations/0002_recommendation_status_lifecycle.py b/Modules/Backend/fertilization/migrations/0002_recommendation_status_lifecycle.py new file mode 100644 index 0000000..e23b588 --- /dev/null +++ b/Modules/Backend/fertilization/migrations/0002_recommendation_status_lifecycle.py @@ -0,0 +1,37 @@ +from django.db import migrations, models + + +PENDING_STATUS = "pending_confirmation" +OLD_STATUSES = {"", "success", "error", None} + + +def migrate_existing_statuses(apps, schema_editor): + Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest") + Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update( + status=PENDING_STATUS + ) + Recommendation.objects.filter(status__isnull=True).update(status=PENDING_STATUS) + + +class Migration(migrations.Migration): + dependencies = [ + ("fertilization_recommendation", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="fertilizationrecommendationrequest", + name="status", + field=models.CharField( + choices=[ + ("in_progress", "در حال مصرف"), + ("pending_confirmation", "منتظر تایید"), + ("completed", "پایان یافته"), + ], + db_index=True, + default="pending_confirmation", + max_length=64, + ), + ), + migrations.RunPython(migrate_existing_statuses, migrations.RunPython.noop), + ] diff --git a/Modules/Backend/fertilization/migrations/0003_fertilizationplan.py b/Modules/Backend/fertilization/migrations/0003_fertilizationplan.py new file mode 100644 index 0000000..e240dfb --- /dev/null +++ b/Modules/Backend/fertilization/migrations/0003_fertilizationplan.py @@ -0,0 +1,44 @@ +import django.db.models.deletion +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("fertilization_recommendation", "0002_recommendation_status_lifecycle"), + ] + + operations = [ + migrations.CreateModel( + name="FertilizationPlan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("plan_payload", models.JSONField(blank=True, default=dict)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("is_deleted", models.BooleanField(db_index=True, default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="fertilization_plans", to="farm_hub.farmhub"), + ), + ( + "recommendation", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="fertilization_recommendation.fertilizationrecommendationrequest"), + ), + ], + options={ + "db_table": "fertilization_plans", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/fertilization/migrations/0004_fertilizationplan_default_inactive.py b/Modules/Backend/fertilization/migrations/0004_fertilizationplan_default_inactive.py new file mode 100644 index 0000000..e676889 --- /dev/null +++ b/Modules/Backend/fertilization/migrations/0004_fertilizationplan_default_inactive.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("fertilization_recommendation", "0003_fertilizationplan"), + ] + + operations = [ + migrations.AlterField( + model_name="fertilizationplan", + name="is_active", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/Modules/Backend/fertilization/migrations/__init__.py b/Modules/Backend/fertilization/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/fertilization/mock_data.py b/Modules/Backend/fertilization/mock_data.py new file mode 100644 index 0000000..d4525e4 --- /dev/null +++ b/Modules/Backend/fertilization/mock_data.py @@ -0,0 +1,44 @@ +""" +Static mock data for Fertilization Recommendation API. +No database, no dynamic values. +""" + +CONFIG_RESPONSE_DATA = { + "farmData": { + "soilType": "Loamy", + "organicMatter": "Medium (2.5%)", + "waterEC": "1.2 dS/m", + }, + "growthStages": [ + {"id": "prePlanting", "icon": "tabler-seedling"}, + {"id": "earlyGrowth", "icon": "tabler-leaf"}, + {"id": "flowering", "icon": "tabler-flower"}, + {"id": "fruiting", "icon": "tabler-apple"}, + {"id": "postHarvest", "icon": "tabler-basket"}, + ], + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], +} + +RECOMMEND_RESPONSE_DATA = { + "plan": { + "npkRatio": "20-20-20 (NPK)", + "amountPerHectare": "150 kg/ha", + "applicationMethod": "Foliar spray + soil broadcast", + "applicationInterval": "Every 14 days", + "reasoning": "Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.", + }, +} + +FERTILIZATION_DASHBOARD_RECOMMENDATION = { + "title": "کود: 20-20-20 (NPK)", + "subtitle": "150 kg/ha، با روش Foliar spray + soil broadcast و هر 14 روز.", + "avatarIcon": "tabler-leaf", + "avatarColor": "success", +} diff --git a/Modules/Backend/fertilization/models.py b/Modules/Backend/fertilization/models.py new file mode 100644 index 0000000..bf3b326 --- /dev/null +++ b/Modules/Backend/fertilization/models.py @@ -0,0 +1,92 @@ +import uuid + +from django.db import models +from django.utils import timezone + +from farm_hub.models import FarmHub + + +class FertilizationRecommendationRequest(models.Model): + STATUS_IN_PROGRESS = "in_progress" + STATUS_PENDING_CONFIRMATION = "pending_confirmation" + STATUS_COMPLETED = "completed" + STATUS_CHOICES = ( + (STATUS_IN_PROGRESS, "در حال مصرف"), + (STATUS_PENDING_CONFIRMATION, "منتظر تایید"), + (STATUS_COMPLETED, "پایان یافته"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="fertilizations", + ) + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + status = models.CharField( + max_length=64, + choices=STATUS_CHOICES, + default=STATUS_PENDING_CONFIRMATION, + db_index=True, + ) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "fertilization_requests" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.task_id or str(self.uuid) + + +class FertilizationPlan(models.Model): + SOURCE_RECOMMENDATION = "recommendation" + SOURCE_FREE_TEXT = "free_text" + SOURCE_CHOICES = ( + (SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"), + (SOURCE_FREE_TEXT, "متن آزاد کاربر"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="fertilization_plans", + ) + source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True) + recommendation = models.ForeignKey( + FertilizationRecommendationRequest, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="plans", + ) + title = models.CharField(max_length=255, blank=True, default="") + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + plan_payload = models.JSONField(default=dict, blank=True) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=False, db_index=True) + is_deleted = models.BooleanField(default=False, db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "fertilization_plans" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.title or self.crop_id or str(self.uuid) + + def soft_delete(self): + self.is_deleted = True + self.is_active = False + self.deleted_at = timezone.now() + self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"]) diff --git a/Modules/Backend/fertilization/postman/fertilization_recommendation.json b/Modules/Backend/fertilization/postman/fertilization_recommendation.json new file mode 100644 index 0000000..112bf54 --- /dev/null +++ b/Modules/Backend/fertilization/postman/fertilization_recommendation.json @@ -0,0 +1 @@ +{"info":{"name":"Fertilization Recommendation","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Fertilization Recommendation API. GET config (farm data, growth stages, crop options). POST recommend (optional body). Returns static plan. No database."},"item":[{"name":"Get config (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/fertilization-recommendation/config/","description":"Returns static farmData, growthStages, cropOptions."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"farmData\": {\n \"soilType\": \"Loamy\",\n \"organicMatter\": \"Medium (2.5%)\",\n \"waterEC\": \"1.2 dS/m\"\n },\n \"growthStages\": [\n {\"id\": \"prePlanting\", \"icon\": \"tabler-seedling\"},\n {\"id\": \"earlyGrowth\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"flowering\", \"icon\": \"tabler-flower\"},\n {\"id\": \"fruiting\", \"icon\": \"tabler-apple\"},\n {\"id\": \"postHarvest\", \"icon\": \"tabler-basket\"}\n ],\n \"cropOptions\": [\n {\"id\": \"wheat\", \"labelKey\": \"wheat\", \"icon\": \"tabler-wheat\"},\n {\"id\": \"corn\", \"labelKey\": \"corn\", \"icon\": \"tabler-plant-2\"},\n {\"id\": \"cotton\", \"labelKey\": \"cotton\", \"icon\": \"tabler-flower\"},\n {\"id\": \"saffron\", \"labelKey\": \"saffron\", \"icon\": \"tabler-flower-2\"},\n {\"id\": \"canola\", \"labelKey\": \"canola\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"vegetables\", \"labelKey\": \"vegetables\", \"icon\": \"tabler-carrot\"}\n ]\n }\n}"}]},{"name":"Get recommendation (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"crop_id\": \"wheat\",\n \"growth_stage\": \"flowering\",\n \"soilType\": \"Loamy\",\n \"organicMatter\": \"Medium (2.5%)\",\n \"waterEC\": \"1.2 dS/m\"\n}"},"url":"{{baseUrl}}/api/fertilization-recommendation/recommend/","description":"Optional body: crop_id, growth_stage, farm_data. Returns static plan. Input not processed."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"plan\": {\n \"npkRatio\": \"20-20-20 (NPK)\",\n \"amountPerHectare\": \"150 kg/ha\",\n \"applicationMethod\": \"Foliar spray + soil broadcast\",\n \"applicationInterval\": \"Every 14 days\",\n \"reasoning\": \"Your loamy soil with medium organic matter (2.5%) provides good nutrient retention. Water EC of 1.2 dS/m indicates low salinity—suitable for most crops. At the flowering stage, increased phosphorus supports bloom development. We recommend a balanced NPK to maintain nitrogen for vegetative growth while boosting phosphorous for flowering.\"\n }\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]} diff --git a/Modules/Backend/fertilization/serializers.py b/Modules/Backend/fertilization/serializers.py new file mode 100644 index 0000000..3b8ea55 --- /dev/null +++ b/Modules/Backend/fertilization/serializers.py @@ -0,0 +1,205 @@ +from rest_framework import serializers + + +class FertilizationFarmDataSerializer(serializers.Serializer): + soilType = serializers.CharField(required=False, allow_blank=True) + organicMatter = serializers.CharField(required=False, allow_blank=True) + waterEC = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationRecommendRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.") + crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول. این فیلد همان plant_name است.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه. این فیلد همان crop_id است.") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") + + +class FertilizationRecommendationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های کودی.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class FertilizationSectionSerializer(serializers.Serializer): + type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"]) + title = serializers.CharField(required=False, allow_blank=True) + icon = serializers.CharField(required=False, allow_blank=True) + content = serializers.CharField(required=False, allow_blank=True) + items = serializers.ListField(child=serializers.CharField(), required=False) + fertilizerType = serializers.CharField(required=False, allow_blank=True) + amount = serializers.CharField(required=False, allow_blank=True) + applicationMethod = serializers.CharField(required=False, allow_blank=True) + timing = serializers.CharField(required=False, allow_blank=True) + validityPeriod = serializers.CharField(required=False, allow_blank=True) + expandableExplanation = serializers.CharField(required=False, allow_blank=True) + + +class NpkRatioSerializer(serializers.Serializer): + n = serializers.FloatField(required=False) + p = serializers.FloatField(required=False) + k = serializers.FloatField(required=False) + label = serializers.CharField(required=False, allow_blank=True) + + +class ApplicationMethodSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + label = serializers.CharField(required=False, allow_blank=True) + + +class ApplicationIntervalSerializer(serializers.Serializer): + value = serializers.FloatField(required=False) + unit = serializers.CharField(required=False, allow_blank=True) + label = serializers.CharField(required=False, allow_blank=True) + + +class DosageSerializer(serializers.Serializer): + base_amount_per_hectare = serializers.FloatField(required=False) + base_amount_per_square_meter = serializers.FloatField(required=False) + unit = serializers.CharField(required=False, allow_blank=True) + label = serializers.CharField(required=False, allow_blank=True) + calculation_basis = serializers.CharField(required=False, allow_blank=True) + + +class PrimaryRecommendationSerializer(serializers.Serializer): + fertilizer_code = serializers.CharField(required=False, allow_blank=True) + fertilizer_name = serializers.CharField(required=False, allow_blank=True) + display_title = serializers.CharField(required=False, allow_blank=True) + fertilizer_type = serializers.CharField(required=False, allow_blank=True) + npk_ratio = NpkRatioSerializer(required=False) + application_method = ApplicationMethodSerializer(required=False) + application_interval = ApplicationIntervalSerializer(required=False) + dosage = DosageSerializer(required=False) + reasoning = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + + +class NutrientItemSerializer(serializers.Serializer): + key = serializers.CharField(required=False, allow_blank=True) + name = serializers.CharField(required=False, allow_blank=True) + value = serializers.FloatField(required=False) + unit = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + + +class NutrientAnalysisSerializer(serializers.Serializer): + macro = NutrientItemSerializer(many=True, read_only=True) + micro = NutrientItemSerializer(many=True, read_only=True) + + +class ApplicationGuideStepSerializer(serializers.Serializer): + step_number = serializers.IntegerField(required=False) + title = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + + +class ApplicationGuideSerializer(serializers.Serializer): + safety_warning = serializers.CharField(required=False, allow_blank=True) + steps = ApplicationGuideStepSerializer(many=True, read_only=True) + + +class AlternativeRecommendationSerializer(serializers.Serializer): + fertilizer_code = serializers.CharField(required=False, allow_blank=True) + fertilizer_name = serializers.CharField(required=False, allow_blank=True) + fertilizer_type = serializers.CharField(required=False, allow_blank=True) + usage_method = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + + +class FertilizationRecommendationListItemSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + fertilizer_type = serializers.CharField(read_only=True, allow_blank=True) + status = serializers.CharField(read_only=True) + status_label = serializers.CharField(source="get_status_display", read_only=True) + requested_at = serializers.DateTimeField(source="created_at", read_only=True) + + +class FreeTextPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.") + answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.") + partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.") + farm_uuid = serializers.UUIDField( + required=False, + allow_null=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه برای context اختیاری.", + ) + + def validate(self, attrs): + has_message = bool((attrs.get("message") or "").strip()) + has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers")) + has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan")) + if not (has_message or has_answers or has_partial_plan): + raise serializers.ValidationError( + {"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]} + ) + return attrs + + +class PlanParserQuestionSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + field = serializers.CharField(required=False, allow_blank=True) + question = serializers.CharField(required=False, allow_blank=True) + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FreeTextPlanParserResponseDataSerializer(serializers.Serializer): + status = serializers.CharField(required=False, allow_blank=True) + status_fa = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + missing_fields = serializers.ListField(child=serializers.CharField(), required=False) + questions = PlanParserQuestionSerializer(many=True, required=False) + collected_data = serializers.DictField(required=False) + final_plan = serializers.DictField(required=False, allow_null=True) + + +class FertilizationRecommendResponseDataSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(read_only=True, required=False) + crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) + plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True) + status = serializers.CharField(read_only=True, required=False) + status_label = serializers.CharField(read_only=True, required=False) + primary_recommendation = PrimaryRecommendationSerializer(read_only=True) + nutrient_analysis = NutrientAnalysisSerializer(read_only=True) + application_guide = ApplicationGuideSerializer(read_only=True) + alternative_recommendations = AlternativeRecommendationSerializer(many=True, read_only=True) + sections = FertilizationSectionSerializer(many=True, read_only=True) + + +class FertilizationPlanListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های کودی.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class FertilizationPlanListItemSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + + +class FertilizationPlanDetailSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + plan_payload = serializers.DictField(read_only=True) + + +class FertilizationPlanStatusUpdateSerializer(serializers.Serializer): + is_active = serializers.BooleanField(required=True) diff --git a/Modules/Backend/fertilization/services.py b/Modules/Backend/fertilization/services.py new file mode 100644 index 0000000..0b89e53 --- /dev/null +++ b/Modules/Backend/fertilization/services.py @@ -0,0 +1,100 @@ +from copy import deepcopy + +from .defaults import FERTILIZATION_DASHBOARD_TEMPLATE +from .models import FertilizationPlan, FertilizationRecommendationRequest + + +def _extract_result(response_payload): + if not isinstance(response_payload, dict): + return {} + + data = response_payload.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + + result = response_payload.get("result") + if isinstance(result, dict): + return result + + return {} + + +def _get_latest_result(farm): + if farm is None: + return {} + + for request in FertilizationRecommendationRequest.objects.filter(farm=farm): + result = _extract_result(request.response_payload) + if result: + return result + + return {} + + +def get_active_plan_payload(farm): + if farm is None: + return {} + + plan = ( + FertilizationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False) + .order_by("-created_at", "-id") + .first() + ) + if plan is None or not isinstance(plan.plan_payload, dict): + return {} + + return deepcopy(plan.plan_payload) + + +def build_active_plan_context(farm): + plan_payload = get_active_plan_payload(farm) + if not plan_payload: + return {} + + context = {"plan_payload": plan_payload} + + primary_recommendation = plan_payload.get("primary_recommendation") + if isinstance(primary_recommendation, dict) and primary_recommendation: + context["primary_recommendation"] = deepcopy(primary_recommendation) + + nutrient_analysis = plan_payload.get("nutrient_analysis") + if isinstance(nutrient_analysis, dict) and nutrient_analysis: + context["nutrient_analysis"] = deepcopy(nutrient_analysis) + + application_guide = plan_payload.get("application_guide") + if isinstance(application_guide, dict) and application_guide: + context["application_guide"] = deepcopy(application_guide) + + alternative_recommendations = plan_payload.get("alternative_recommendations") + if isinstance(alternative_recommendations, list) and alternative_recommendations: + context["alternative_recommendations"] = deepcopy(alternative_recommendations) + + sections = plan_payload.get("sections") + if isinstance(sections, list) and sections: + context["sections"] = deepcopy(sections) + + return context + + +def get_fertilization_dashboard_recommendation(farm=None): + default_item = deepcopy(FERTILIZATION_DASHBOARD_TEMPLATE) + result = _get_latest_result(farm) + plan = result.get("plan") or {} + if not isinstance(plan, dict) or not plan: + return default_item + + npk_ratio = plan.get("npkRatio") or "20-20-20 (NPK)" + amount = plan.get("amountPerHectare") + method = plan.get("applicationMethod") + interval = plan.get("applicationInterval") + + subtitle_parts = [part for part in [amount, method, interval] if part] + + default_item["title"] = f"کود: {npk_ratio}" + if subtitle_parts: + default_item["subtitle"] = "، ".join(subtitle_parts) + default_item["status"] = "success" + default_item["source"] = "db" + default_item["warnings"] = [] + + return default_item diff --git a/Modules/Backend/fertilization/tests.py b/Modules/Backend/fertilization/tests.py new file mode 100644 index 0000000..757d0cd --- /dev/null +++ b/Modules/Backend/fertilization/tests.py @@ -0,0 +1,553 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate +from unittest.mock import patch + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from farmer_calendar.models import FarmerCalendarEvent +from .models import FertilizationPlan, FertilizationRecommendationRequest +from .views import ( + FertilizationPlanDetailView, + FertilizationPlanListView, + FertilizationPlanStatusView, + PlanFromTextView, + RecommendationDetailView, + RecommendationListView, + RecommendView, +) + + +class FertilizationRecommendViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="fert-user", + password="secret123", + email="fert@example.com", + phone_number="09125556677", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-farm") + + @patch("fertilization.views.external_api_request") + def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "needs_clarification", + "status_fa": "نیازمند پرسش تکمیلی", + "summary": "need more", + "missing_fields": ["growth_stage"], + "questions": [{"id": "growth_stage", "field": "growth_stage", "question": "?", "rationale": "!"}], + "collected_data": {"crop_name": "گندم"}, + "final_plan": None, + }, + }, + ) + + request = self.factory.post( + "/api/fertilization/plan-from-text/", + {"message": "متن کودهی", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "needs_clarification") + self.assertEqual(FertilizationPlan.objects.count(), 0) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/fertilization/plan-from-text/", + method="POST", + payload={"message": "متن کودهی", "farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_plan_from_text_requires_message_or_answers_or_partial_plan(self): + request = self.factory.post("/api/fertilization/plan-from-text/", {}, format="json") + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + + @patch("fertilization.views.external_api_request") + def test_recommend_returns_updated_response_shape(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "success", + "data": { + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_name": "NPK 20-20-20", + "display_title": "کود کامل متعادل", + "fertilizer_type": "NPK", + "npk_ratio": {"n": 20, "p": 20, "k": 20, "label": "20-20-20"}, + "application_method": {"id": "fertigation", "label": "کودآبیاری"}, + "application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"}, + "dosage": { + "base_amount_per_hectare": 65, + "base_amount_per_square_meter": 0.0065, + "unit": "kg", + "label": "65 کیلوگرم در هکتار", + "calculation_basis": "engine-v2", + }, + "reasoning": "متعادل برای فاز رشد", + "summary": "مصرف منظم در این مرحله توصیه می شود", + }, + "nutrient_analysis": { + "macro": [ + {"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent", "description": "تقویت رشد رویشی"} + ], + "micro": [ + {"key": "zn", "name": "Zinc", "value": 2.5, "unit": "percent", "description": "بهبود رشد"} + ], + }, + "application_guide": { + "safety_warning": "در ساعات خنک مصرف شود", + "steps": [ + {"step_number": 1, "title": "حل کردن", "description": "کود را در آب حل کنید"} + ], + }, + "alternative_recommendations": [ + { + "fertilizer_code": "npk-121236", + "fertilizer_name": "NPK 12-12-36", + "fertilizer_type": "NPK", + "usage_method": "fertigation", + "description": "برای نیاز پتاس بالا", + } + ], + "sections": [ + {"type": "recommendation", "title": "پیشنهاد اصلی", "icon": "leaf", "content": "NPK 20-20-20"} + ], + }, + }, + ) + + request = self.factory.post( + "/api/fertilization/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "crop_id": "گندم", "growth_stage": "vegetative"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertIn("primary_recommendation", response.data["data"]) + self.assertIn("nutrient_analysis", response.data["data"]) + self.assertIn("application_guide", response.data["data"]) + self.assertIn("alternative_recommendations", response.data["data"]) + self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_code"], "npk-202020") + self.assertEqual(response.data["data"]["primary_recommendation"]["application_interval"]["value"], 14.0) + self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation") + self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation") + self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1) + self.assertEqual(FertilizationPlan.objects.count(), 1) + saved_request = FertilizationRecommendationRequest.objects.get() + saved_plan = FertilizationPlan.objects.get() + self.assertEqual(saved_request.crop_id, "گندم") + self.assertEqual(saved_request.growth_stage, "vegetative") + self.assertEqual(saved_plan.source, FertilizationPlan.SOURCE_RECOMMENDATION) + self.assertEqual(saved_plan.recommendation_id, saved_request.id) + self.assertFalse(saved_plan.is_active) + self.assertFalse(saved_plan.is_deleted) + self.assertEqual(saved_plan.plan_payload["primary_recommendation"]["fertilizer_code"], "npk-202020") + self.assertEqual( + saved_request.status, + FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + ) + self.assertEqual( + saved_request.response_payload["data"]["primary_recommendation"]["fertilizer_code"], + "npk-202020", + ) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/fertilization/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "vegetative", + }, + ) + + @patch("fertilization.views.external_api_request") + def test_recommend_accepts_plant_name_and_passes_it_directly_to_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}}) + + request = self.factory.post( + "/api/fertilization/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "جو", "growth_stage": "flowering"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + saved_request = FertilizationRecommendationRequest.objects.latest("created_at") + self.assertEqual(saved_request.crop_id, "جو") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/fertilization/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "crop_id": "جو", + "plant_name": "جو", + "growth_stage": "flowering", + }, + ) + + @patch("fertilization.views.external_api_request") + def test_recommend_includes_active_fertilization_plan_in_ai_payload(self, mock_external_api_request): + FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه فعال", + crop_id="گندم", + growth_stage="vegetative", + plan_payload={ + "primary_recommendation": {"fertilizer_code": "npk-101010", "fertilizer_name": "NPK 10-10-10"}, + "nutrient_analysis": {"macro": [{"key": "n", "value": 10}]}, + "application_guide": {"steps": [{"step_number": 1, "title": "مرحله اول"}]}, + "sections": [{"type": "recommendation", "title": "اصلی"}], + }, + is_active=True, + ) + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}}) + + request = self.factory.post( + "/api/fertilization/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "crop_id": "گندم", "growth_stage": "vegetative"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertIn("active_fertilization_plan", sent_payload) + self.assertEqual( + sent_payload["active_fertilization_plan"]["primary_recommendation"]["fertilizer_code"], + "npk-101010", + ) + + @patch("fertilization.views.external_api_request") + def test_plan_from_text_creates_plan_when_final_plan_exists(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "final_plan": { + "title": "برنامه کوددهی گندم", + "crop_name": "گندم", + "growth_stage": "flowering", + "items": [{"name": "NPK 20-20-20"}], + }, + }, + }, + ) + + request = self.factory.post( + "/api/fertilization/plan-from-text/", + {"message": "برنامه کودی", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(FertilizationPlan.objects.count(), 1) + plan = FertilizationPlan.objects.get() + self.assertEqual(plan.source, FertilizationPlan.SOURCE_FREE_TEXT) + self.assertEqual(plan.title, "برنامه کوددهی گندم") + self.assertEqual(plan.crop_id, "گندم") + self.assertEqual(plan.growth_stage, "flowering") + self.assertFalse(plan.is_active) + self.assertFalse(plan.is_deleted) + + def test_recommendation_list_returns_paginated_summary_items(self): + first = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_type": "NPK", + } + } + }, + ) + second = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="ذرت", + growth_stage="flowering", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_type": "Micronutrient", + } + } + }, + ) + + request = self.factory.get( + f"/api/fertilization/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1" + ) + force_authenticate(request, user=self.user) + + response = RecommendationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["pagination"]["page"], 1) + self.assertEqual(response.data["pagination"]["page_size"], 1) + self.assertEqual(response.data["pagination"]["total_pages"], 2) + self.assertEqual(response.data["pagination"]["total_items"], 2) + self.assertTrue(response.data["pagination"]["has_next"]) + self.assertFalse(response.data["pagination"]["has_previous"]) + self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid)) + self.assertEqual(response.data["data"][0]["plant_name"], "ذرت") + self.assertEqual(response.data["data"][0]["growth_stage"], "flowering") + self.assertEqual(response.data["data"][0]["fertilizer_type"], "Micronutrient") + self.assertEqual(response.data["data"][0]["status"], "pending_confirmation") + self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید") + self.assertIn("requested_at", response.data["data"][0]) + self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid)) + + def test_recommendation_detail_returns_same_shape_as_recommend_endpoint(self): + recommendation = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_type": "NPK", + "summary": "خلاصه توصیه", + }, + "nutrient_analysis": { + "macro": [{"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent"}], + "micro": [], + }, + "application_guide": { + "safety_warning": "در هوای خنک استفاده شود", + "steps": [{"step_number": 1, "title": "آماده سازی", "description": "در آب حل شود"}], + }, + "alternative_recommendations": [ + {"fertilizer_code": "alt-1", "fertilizer_name": "Alt", "fertilizer_type": "NPK"} + ], + "sections": [{"type": "warning", "title": "هشدار", "content": "اختلاط نشود"}], + } + }, + ) + + request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_code"], "npk-202020") + self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_type"], "NPK") + self.assertEqual(response.data["data"]["nutrient_analysis"]["macro"][0]["value"], 20.0) + self.assertEqual(response.data["data"]["application_guide"]["steps"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "warning") + self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid)) + self.assertEqual(response.data["data"]["crop_id"], "گندم") + self.assertEqual(response.data["data"]["plant_name"], "گندم") + self.assertEqual(response.data["data"]["status"], "pending_confirmation") + self.assertEqual(response.data["data"]["status_label"], "منتظر تایید") + + def test_recommendation_detail_falls_back_to_top_level_fertilizer_code(self): + recommendation = FertilizationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + response_payload={ + "data": { + "fertilizer_code": "legacy-code-101", + "fertilizer_type": "NPK", + } + }, + ) + + request = self.factory.get(f"/api/fertilization/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["data"]["primary_recommendation"]["fertilizer_code"], + "legacy-code-101", + ) + + +class FertilizationPlanApiTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="fert-plan-user", + password="secret123", + email="fert-plan@example.com", + phone_number="09123334455", + ) + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="fert-plan-farm") + self.plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه نمونه", + crop_id="گوجه", + growth_stage="flowering", + plan_payload={"items": [{"title": "مرحله اول"}]}, + ) + + def test_plan_list_returns_non_deleted_plans(self): + FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_RECOMMENDATION, + title="حذف شده", + is_deleted=True, + is_active=False, + ) + + request = self.factory.get(f"/api/fertilization/plans/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = FertilizationPlanListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid)) + self.assertEqual(response.data["data"][0]["source"], FertilizationPlan.SOURCE_FREE_TEXT) + + def test_plan_detail_returns_plan_payload(self): + request = self.factory.get(f"/api/fertilization/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid)) + self.assertEqual(response.data["data"]["plan_payload"]["items"][0]["title"], "مرحله اول") + + def test_plan_delete_is_soft_delete(self): + request = self.factory.delete(f"/api/fertilization/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = FertilizationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertTrue(self.plan.is_deleted) + self.assertFalse(self.plan.is_active) + + def test_plan_status_patch_updates_is_active(self): + request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + + def test_activating_one_plan_deactivates_other_active_plan(self): + other_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه دوم", + is_active=True, + ) + + request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = FertilizationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + other_plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + self.assertFalse(other_plan.is_active) + + def test_plan_status_patch_syncs_calendar_events(self): + self.plan.plan_payload = { + "primary_recommendation": { + "fertilizer_code": "npk-202020", + "fertilizer_name": "NPK 20-20-20", + "application_interval": {"value": 14, "unit": "day", "label": "هر 14 روز"}, + }, + "application_guide": { + "steps": [ + {"step_number": 1, "title": "مرحله اول", "description": "در آب حل شود", "date": "2025-02-14"} + ] + }, + } + self.plan.is_active = False + self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"]) + + activate_request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(activate_request, user=self.user) + + activate_response = FertilizationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(activate_response.status_code, 200) + events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)) + self.assertEqual(events.count(), 1) + self.assertEqual(events.first().extended_props["plan_type"], "fertilization") + + deactivate_request = self.factory.patch( + f"/api/fertilization/plans/{self.plan.uuid}/status/", + {"is_active": False}, + format="json", + ) + force_authenticate(deactivate_request, user=self.user) + + deactivate_response = FertilizationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(deactivate_response.status_code, 200) + self.assertFalse( + FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists() + ) diff --git a/Modules/Backend/fertilization/urls.py b/Modules/Backend/fertilization/urls.py new file mode 100644 index 0000000..c924568 --- /dev/null +++ b/Modules/Backend/fertilization/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from .views import ( + ConfigView, + FertilizationPlanDetailView, + FertilizationPlanListView, + FertilizationPlanStatusView, + PlanFromTextView, + RecommendationDetailView, + RecommendationListView, + RecommendView, +) + +urlpatterns = [ + path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"), + path("plans/", FertilizationPlanListView.as_view(), name="fertilization-plan-list"), + path("plans//", FertilizationPlanDetailView.as_view(), name="fertilization-plan-detail"), + path("plans//status/", FertilizationPlanStatusView.as_view(), name="fertilization-plan-status"), + path("recommendations//", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"), + path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"), + path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"), + path("plan-from-text/", PlanFromTextView.as_view(), name="fertilization-plan-from-text"), +] diff --git a/Modules/Backend/fertilization/views.py b/Modules/Backend/fertilization/views.py new file mode 100644 index 0000000..3098b12 --- /dev/null +++ b/Modules/Backend/fertilization/views.py @@ -0,0 +1,708 @@ +""" +Fertilization Recommendation API views. +""" + +import logging + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from config.swagger import code_response, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from farmer_calendar import PLAN_TYPE_FERTILIZATION, delete_plan_events, sync_plan_events +from .models import FertilizationPlan, FertilizationRecommendationRequest +from .services import build_active_plan_context +from .defaults import CONFIG_RESPONSE_TEMPLATE +from .serializers import ( + FreeTextPlanParserRequestSerializer, + FreeTextPlanParserResponseDataSerializer, + FertilizationPlanDetailSerializer, + FertilizationPlanListItemSerializer, + FertilizationPlanListQuerySerializer, + FertilizationPlanStatusUpdateSerializer, + FertilizationRecommendationListItemSerializer, + FertilizationRecommendationListQuerySerializer, + FertilizationRecommendRequestSerializer, + FertilizationRecommendResponseDataSerializer, +) + + +logger = logging.getLogger(__name__) + + +class FertilizationRecommendationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data): + page_size = self.get_page_size(self.request) or self.page.paginator.per_page + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "pagination": { + "page": self.page.number, + "page_size": page_size, + "total_pages": self.page.paginator.num_pages, + "total_items": self.page.paginator.count, + "has_next": self.page.has_next(), + "has_previous": self.page.has_previous(), + "next": self.get_next_link(), + "previous": self.get_previous_link(), + }, + }, + status=status.HTTP_200_OK, + ) + + +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class ConfigView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + responses={200: status_response("FertilizationConfigResponse", data=serializers.JSONField())}, + ) + def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + data = dict(CONFIG_RESPONSE_TEMPLATE) + data["farm_uuid"] = str(farm.farm_uuid) + return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) + + +class RecommendView(FarmAccessMixin, APIView): + @staticmethod + def _to_string(value): + if value is None: + return "" + return str(value) + + @staticmethod + def _to_float(value): + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _to_int(value): + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "icon", + "content", + "items", + "fertilizerType", + "amount", + "applicationMethod", + "timing", + "validityPeriod", + "expandableExplanation", + } + + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + @staticmethod + def _extract_public_payload(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict): + result = data.get("result") + if isinstance(result, dict): + return result + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + def _normalize_npk_ratio(self, raw_ratio): + if not isinstance(raw_ratio, dict): + return {} + + normalized = {} + for key in ("n", "p", "k"): + numeric_value = self._to_float(raw_ratio.get(key)) + if numeric_value is not None: + normalized[key] = numeric_value + + label = self._to_string(raw_ratio.get("label")).strip() + if label: + normalized["label"] = label + + return normalized + + def _normalize_named_object(self, raw_object): + if not isinstance(raw_object, dict): + return {} + + normalized = {} + for key in ("id", "label", "unit", "calculation_basis"): + value = self._to_string(raw_object.get(key)).strip() + if value: + normalized[key] = value + + for key in ("value", "base_amount_per_hectare", "base_amount_per_square_meter"): + numeric_value = self._to_float(raw_object.get(key)) + if numeric_value is not None: + normalized[key] = numeric_value + + return normalized + + @staticmethod + def _first_non_empty(*values): + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + def _normalize_primary_recommendation(self, payload): + raw_data = payload.get("primary_recommendation") + if not isinstance(raw_data, dict): + raw_data = {} + + normalized = {} + scalar_fields = { + "fertilizer_code": ( + raw_data.get("fertilizer_code"), + payload.get("fertilizer_code"), + ), + "fertilizer_name": ( + raw_data.get("fertilizer_name"), + payload.get("fertilizer_name"), + ), + "display_title": ( + raw_data.get("display_title"), + payload.get("display_title"), + ), + "fertilizer_type": ( + raw_data.get("fertilizer_type"), + payload.get("fertilizer_type"), + ), + "reasoning": ( + raw_data.get("reasoning"), + payload.get("reasoning"), + ), + "summary": ( + raw_data.get("summary"), + payload.get("summary"), + ), + } + for key, values in scalar_fields.items(): + value = self._first_non_empty(*values) + if value: + normalized[key] = value + + npk_ratio = self._normalize_npk_ratio(raw_data.get("npk_ratio")) + if npk_ratio: + normalized["npk_ratio"] = npk_ratio + + application_method = self._normalize_named_object(raw_data.get("application_method")) + if application_method: + normalized["application_method"] = { + key: value for key, value in application_method.items() if key in {"id", "label"} + } + + application_interval = self._normalize_named_object(raw_data.get("application_interval")) + if application_interval: + normalized["application_interval"] = { + key: value for key, value in application_interval.items() if key in {"value", "unit", "label"} + } + + dosage = self._normalize_named_object(raw_data.get("dosage")) + if dosage: + dosage_label = self._to_string(raw_data.get("dosage", {}).get("label")).strip() + if dosage_label: + dosage["label"] = dosage_label + normalized["dosage"] = { + key: value + for key, value in dosage.items() + if key in {"base_amount_per_hectare", "base_amount_per_square_meter", "unit", "label", "calculation_basis"} + } + + return normalized + + def _normalize_nutrient_items(self, items): + if not isinstance(items, list): + return [] + + normalized_items = [] + for item in items: + if not isinstance(item, dict): + continue + normalized_item = {} + for key in ("key", "name", "unit", "description"): + value = self._to_string(item.get(key)).strip() + if value: + normalized_item[key] = value + numeric_value = self._to_float(item.get("value")) + if numeric_value is not None: + normalized_item["value"] = numeric_value + if normalized_item: + normalized_items.append(normalized_item) + return normalized_items + + def _normalize_application_guide(self, payload): + raw_data = payload.get("application_guide") + if not isinstance(raw_data, dict): + return {} + + normalized = {} + safety_warning = self._to_string(raw_data.get("safety_warning")).strip() + if safety_warning: + normalized["safety_warning"] = safety_warning + + raw_steps = raw_data.get("steps") + if isinstance(raw_steps, list): + steps = [] + for step in raw_steps: + if not isinstance(step, dict): + continue + normalized_step = {} + step_number = self._to_int(step.get("step_number")) + if step_number is not None: + normalized_step["step_number"] = step_number + for key in ("title", "description"): + value = self._to_string(step.get(key)).strip() + if value: + normalized_step[key] = value + if normalized_step: + steps.append(normalized_step) + normalized["steps"] = steps + + return normalized + + def _normalize_alternatives(self, payload): + raw_items = payload.get("alternative_recommendations") + if not isinstance(raw_items, list): + return [] + + alternatives = [] + for item in raw_items: + if not isinstance(item, dict): + continue + normalized_item = {} + for key in ("fertilizer_code", "fertilizer_name", "fertilizer_type", "usage_method", "description"): + value = self._to_string(item.get(key)).strip() + if value: + normalized_item[key] = value + if normalized_item: + alternatives.append(normalized_item) + return alternatives + + def _normalize_response_payload(self, adapter_data): + payload = self._extract_public_payload(adapter_data) + if not isinstance(payload, dict): + payload = {} + + normalized_sections = self._normalize_sections(payload.get("sections")) + nutrient_analysis = payload.get("nutrient_analysis") if isinstance(payload.get("nutrient_analysis"), dict) else {} + + return { + "primary_recommendation": self._normalize_primary_recommendation(payload), + "nutrient_analysis": { + "macro": self._normalize_nutrient_items(nutrient_analysis.get("macro")), + "micro": self._normalize_nutrient_items(nutrient_analysis.get("micro")), + }, + "application_guide": self._normalize_application_guide(payload), + "alternative_recommendations": self._normalize_alternatives(payload), + "sections": normalized_sections, + } + + @staticmethod + def _build_plan_title(crop_id, growth_stage, primary_recommendation): + fertilizer_name = str(primary_recommendation.get("display_title") or primary_recommendation.get("fertilizer_name") or "").strip() + parts = [part for part in [fertilizer_name, crop_id, growth_stage] if part] + return " - ".join(parts) if parts else "برنامه کودی" + + def _create_plan_from_recommendation(self, recommendation, public_data): + primary_recommendation = public_data.get("primary_recommendation", {}) + plan = FertilizationPlan.objects.create( + farm=recommendation.farm, + source=FertilizationPlan.SOURCE_RECOMMENDATION, + recommendation=recommendation, + title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, primary_recommendation), + crop_id=recommendation.crop_id, + growth_stage=recommendation.growth_stage, + plan_payload=public_data, + request_payload=recommendation.request_payload, + response_payload=recommendation.response_payload, + ) + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) + + @staticmethod + def _enrich_ai_payload(payload, farm): + enriched_payload = payload.copy() + active_plan_context = build_active_plan_context(farm) + if active_plan_context: + enriched_payload["active_fertilization_plan"] = active_plan_context + return enriched_payload + + @extend_schema( + tags=["Fertilization Recommendation"], + request=FertilizationRecommendRequestSerializer, + responses={200: code_response("FertilizationRecommendResponse", data=FertilizationRecommendResponseDataSerializer())}, + ) + def post(self, request): + serializer = FertilizationRecommendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + farm = self._get_farm(request, payload.get("farm_uuid")) + crop_id = self._first_non_empty(payload.get("crop_id"), payload.get("plant_name")) + plant_name = self._first_non_empty(payload.get("plant_name"), payload.get("crop_id")) + payload["farm_uuid"] = str(farm.farm_uuid) + payload["crop_id"] = crop_id + payload["plant_name"] = plant_name + payload["growth_stage"] = payload.get("growth_stage", "") + ai_payload = self._enrich_ai_payload(payload, farm) + + adapter_response = external_api_request( + "ai", + "/api/fertilization/recommend/", + method="POST", + payload=ai_payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + public_data = self._normalize_response_payload(response_data) + + logger.warning( + "Fertilization recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", + str(farm.farm_uuid), + adapter_response.status_code, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(public_data.get("sections", [])), + ) + + recommendation = FertilizationRecommendationRequest.objects.create( + farm=farm, + crop_id=crop_id, + growth_stage=payload.get("growth_stage", ""), + task_id="", + status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + request_payload=ai_payload, + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, + ) + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) + + self._create_plan_from_recommendation(recommendation, public_data) + + return Response( + { + "code": 200, + "msg": "success", + "data": public_data, + }, + status=status.HTTP_200_OK, + ) + + +class RecommendationListView(FarmAccessMixin, APIView): + permission_classes = RecommendView.permission_classes + pagination_class = FertilizationRecommendationPagination + + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[FertilizationRecommendationListQuerySerializer], + responses={200: code_response("FertilizationRecommendationListResponse")}, + ) + def get(self, request): + serializer = FertilizationRecommendationListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + recommendations = farm.fertilizations.all().order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(recommendations, request, view=self) + + items = [] + view_helper = RecommendView() + for recommendation in page: + normalized_payload = view_helper._normalize_response_payload(recommendation.response_payload) + recommendation.fertilizer_type = ( + normalized_payload.get("primary_recommendation", {}).get("fertilizer_type", "") + ) + items.append(recommendation) + + data = FertilizationRecommendationListItemSerializer(items, many=True).data + return paginator.get_paginated_response(data) + + +class RecommendationDetailView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[ + OpenApiParameter( + name="recommendation_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + 200: code_response("FertilizationRecommendationDetailResponse", data=FertilizationRecommendResponseDataSerializer()), + 404: code_response("FertilizationRecommendationDetailNotFoundResponse"), + }, + ) + def get(self, request, recommendation_uuid): + recommendation = FertilizationRecommendationRequest.objects.filter( + uuid=recommendation_uuid, + farm__owner=request.user, + ).select_related("farm").first() + if recommendation is None: + return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND) + + view_helper = RecommendView() + data = view_helper._normalize_response_payload(recommendation.response_payload) + data["recommendation_uuid"] = str(recommendation.uuid) + data["crop_id"] = recommendation.crop_id + data["plant_name"] = recommendation.crop_id + data["growth_stage"] = recommendation.growth_stage + data["status"] = recommendation.status + data["status_label"] = recommendation.get_status_display() + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class PlanFromTextView(FarmAccessMixin, APIView): + @staticmethod + def _extract_final_plan(response_data): + if not isinstance(response_data, dict): + return None + data = response_data.get("data") + if isinstance(data, dict): + final_plan = data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + final_plan = response_data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + return None + + @staticmethod + def _build_free_text_plan_title(final_plan): + if not isinstance(final_plan, dict): + return "برنامه کودی" + for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"): + value = str(final_plan.get(key, "")).strip() + if value: + return value + return "برنامه کودی" + + @extend_schema( + tags=["Fertilization Recommendation"], + request=FreeTextPlanParserRequestSerializer, + responses={200: code_response("FertilizationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())}, + ) + def post(self, request): + serializer = FreeTextPlanParserRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm_uuid = payload.get("farm_uuid") + if farm_uuid: + farm = self._get_farm(request, farm_uuid) + payload["farm_uuid"] = str(farm.farm_uuid) + + adapter_response = external_api_request( + "ai", + "/api/fertilization/plan-from-text/", + method="POST", + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + final_plan = self._extract_final_plan(response_data) + if final_plan and farm_uuid: + plan = FertilizationPlan.objects.create( + farm=farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title=self._build_free_text_plan_title(final_plan), + crop_id=str( + final_plan.get("crop_id") + or final_plan.get("crop_name") + or final_plan.get("plant_name") + or "" + ).strip(), + growth_stage=str(final_plan.get("growth_stage") or "").strip(), + plan_payload=final_plan, + request_payload=payload, + response_payload=response_data, + ) + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) + + return Response( + {"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)}, + status=status.HTTP_200_OK, + ) + + +class FertilizationPlanListView(FarmAccessMixin, APIView): + pagination_class = FertilizationRecommendationPagination + + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[FertilizationPlanListQuerySerializer], + responses={200: code_response("FertilizationPlanListResponse")}, + ) + def get(self, request): + serializer = FertilizationPlanListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + plans = farm.fertilization_plans.filter(is_deleted=False).order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(plans, request, view=self) + data = FertilizationPlanListItemSerializer(page, many=True).data + return paginator.get_paginated_response(data) + + +class FertilizationPlanDetailView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + parameters=[ + OpenApiParameter( + name="plan_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={200: code_response("FertilizationPlanDetailResponse", data=FertilizationPlanDetailSerializer())}, + ) + def get(self, request, plan_uuid): + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).select_related("farm").first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + data = FertilizationPlanDetailSerializer(plan).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Fertilization Recommendation"], + responses={200: status_response("FertilizationPlanDeleteResponse", data=serializers.JSONField())}, + ) + def delete(self, request, plan_uuid): + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + plan.soft_delete() + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid) + return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK) + + +class FertilizationPlanStatusView(APIView): + @extend_schema( + tags=["Fertilization Recommendation"], + request=FertilizationPlanStatusUpdateSerializer, + responses={200: code_response("FertilizationPlanStatusResponse", data=serializers.JSONField())}, + ) + def patch(self, request, plan_uuid): + serializer = FertilizationPlanStatusUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + new_is_active = serializer.validated_data["is_active"] + if new_is_active: + FertilizationPlan.objects.filter( + farm=plan.farm, + is_deleted=False, + is_active=True, + ).exclude(pk=plan.pk).update(is_active=False) + + plan.is_active = new_is_active + plan.save(update_fields=["is_active", "updated_at"]) + if plan.is_active: + sync_plan_events(plan, PLAN_TYPE_FERTILIZATION) + else: + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_FERTILIZATION, plan_uuid=plan.uuid) + return Response( + {"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/irrigation/API_REFERENCE_FA.md b/Modules/Backend/irrigation/API_REFERENCE_FA.md new file mode 100644 index 0000000..37b4ac0 --- /dev/null +++ b/Modules/Backend/irrigation/API_REFERENCE_FA.md @@ -0,0 +1,492 @@ +# مستند API آبیاری و محصولات انتخاب‌شده + +این فایل برای تحویل به فرانت نوشته شده و endpointهای مرتبط با آبیاری را به‌صورت کامل توضیح می‌دهد. + +محدوده این مستند: +- همه endpointهای `irrigation/urls.py` +- endpoint دریافت محصولات انتخاب‌شده مزرعه: `GET /api/plants/selected/` + +## نکات عمومی + +- همه endpointها نیاز به authentication کاربر دارند، مگر اینکه در gateway یا لایه بالاتر خلاف آن تنظیم شده باشد. +- در همه endpointهای وابسته به مزرعه، `farm_uuid` باید متعلق به همان کاربر لاگین‌شده باشد. +- فرمت کلی پاسخ‌های موفق در این backend معمولاً به شکل زیر است: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +- در خطاهای اعتبارسنجی معمولاً ساختار زیر برمی‌گردد: + +```json +{ + "farm_uuid": [ + "This field is required." + ] +} +``` + +یا در بعضی endpointها: + +```json +{ + "code": 404, + "msg": "error", + "data": { + "farm_uuid": [ + "Farm not found." + ] + } +} +``` + +--- + +# 1) محصولات انتخاب‌شده مزرعه + +## GET `/api/plants/selected/` + +این endpoint برای گرفتن محصول/محصولات انتخاب‌شده یک مزرعه استفاده می‌شود؛ یعنی همان محصولاتی که روی خود `FarmHub.products` ذخیره شده‌اند. + +این endpoint برای فرانت مفید است وقتی می‌خواهید: +- محصول فعلی مزرعه را نمایش دهید +- لیست گیاه‌های متصل به مزرعه را برای انتخاب stage یا recommendation استفاده کنید +- قبل از درخواست recommendation، محصول‌های مرتبط با همان مزرعه را بخوانید + +### Query Params + +#### `farm_uuid` +- نوع: `string (uuid)` +- اجباری: بله +- توضیح: شناسه مزرعه برای خواندن محصولات انتخاب‌شده آن. + +### نمونه درخواست + +```bash +curl -s "http://localhost:8000/api/plants/selected/?farm_uuid=11111111-1111-1111-1111-111111111111" \ + -H "accept: application/json" \ + -H "Authorization: Bearer " +``` + +### پاسخ موفق + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "name": "گوجه فرنگی", + "icon": "tabler-carrot", + "growth_stages": ["رویشی", "گلدهی", "میوه دهی"] + } + ] +} +``` + +### فیلدهای هر آیتم + +#### `name` +- نوع: `string` +- توضیح: نام محصول. + +#### `icon` +- نوع: `string` +- توضیح: آیکون پیشنهادی برای UI. + +#### `growth_stages` +- نوع: `array` +- توضیح: مراحل رشد قابل استفاده برای فرانت. + +### خطاهای رایج + +#### اگر `farm_uuid` ارسال نشود +```json +{ + "farm_uuid": ["This field is required."] +} +``` + +#### اگر مزرعه متعلق به کاربر نباشد یا پیدا نشود +```json +{ + "farm_uuid": ["Farm not found."] +} +``` + +# 5) تولید recommendation آبیاری + +## POST `/api/irrigation/recommend/` + +این endpoint recommendation آبیاری را تولید می‌کند و خروجی آن با UI فعلی recommendation هماهنگ شده است. + +نکته مهم: +- روش آبیاری از body فرانت خوانده نمی‌شود. +- backend روش آبیاری را از خود مزرعه (`FarmHub.irrigation_method_id` و `FarmHub.irrigation_method_name`) برمی‌دارد. +- بنابراین قبل از صدا زدن این endpoint، فرانت باید روش آبیاری انتخاب‌شده را روی مزرعه ذخیره کرده باشد. + +## ساختار کلی پاسخ + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "...", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "plan": {}, + "water_balance": {}, + "timeline": [], + "sections": [] + } +} +``` + +## Request + +### حداقل payload پیشنهادی + +```json +{ + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی" +} +``` + +### فیلدهای Request + +### `farm_uuid` +- نوع: `string` +- اجباری: بله +- توضیح: شناسه یکتای مزرعه. + +### `sensor_uuid` +- نوع: `string` +- اجباری: خیر +- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود. + +### `plant_name` +- نوع: `string` +- اجباری: خیر +- توضیح: نام گیاه هدف برای تولید recommendation. + +### `growth_stage` +- نوع: `string` +- اجباری: خیر +- توضیح: مرحله رشد گیاه، مثل `رویشی`، `گلدهی` یا `میوه دهی`. + +## فیلدهای `data` + +### `recommendation_uuid` +- نوع: `string (uuid)` +- توضیح: شناسه recommendation ذخیره‌شده برای history/detail. + +### `crop_id` +- نوع: `string` +- توضیح: نام/شناسه گیاه ذخیره‌شده روی recommendation. + +### `plant_name` +- نوع: `string` +- توضیح: معادل `crop_id` برای مصرف آسان‌تر در UI. + +### `growth_stage` +- نوع: `string` +- توضیح: مرحله رشد ذخیره‌شده همراه recommendation. + +### `irrigation_method_name` +- نوع: `string` +- توضیح: نام روش آبیاری خوانده‌شده از مزرعه. + +### `status` +- نوع: `string` +- توضیح: وضعیت recommendation. مقادیر فعلی: + - `in_progress` + - `pending_confirmation` + - `completed` + - `error` + +### `status_label` +- نوع: `string` +- توضیح: متن فارسی وضعیت برای نمایش مستقیم در UI. + +### `plan` +- نوع: `object` +- توضیح: خلاصه اصلی recommendation برای کارت بالای UI. + +### `water_balance` +- نوع: `object` +- توضیح: تراز آب و خروجی محاسبات روزانه. + +### `timeline` +- نوع: `array` +- توضیح: مراحل اجرایی recommendation برای stepper. + +### `sections` +- نوع: `array` +- توضیح: هشدارها و نکات تکمیلی. + +## نمونه پاسخ حداقلی قابل استفاده + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "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": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند" + } + ] + } +} +``` + +--- + +# 6) لیست recommendationهای آبیاری + +## GET `/api/irrigation/recommendations/` + +این endpoint history recommendationهای آبیاری یک مزرعه را برمی‌گرداند. + +### Query Params + +#### `farm_uuid` +- نوع: `string (uuid)` +- اجباری: بله + +#### `page` +- نوع: `number` +- اجباری: خیر +- پیش‌فرض: `1` + +#### `page_size` +- نوع: `number` +- اجباری: خیر +- پیش‌فرض backend: `10` +- حداکثر: `100` + +### نمونه درخواست + +```bash +curl -s "http://localhost:8000/api/irrigation/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10" \ + -H "accept: application/json" \ + -H "Authorization: Bearer " +``` + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "pending_confirmation", + "status_label": "منتظر تایید", + "requested_at": "2025-02-12T09:30:00Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 1, + "total_items": 1, + "has_next": false, + "has_previous": false, + "next": null, + "previous": null + } +} +``` + +### فیلدهای هر آیتم + +#### `recommendation_uuid` +- نوع: `string (uuid)` +- توضیح: شناسه recommendation برای باز کردن جزئیات. + +#### `crop_id` +- نوع: `string` +- توضیح: نام/شناسه گیاه. + +#### `plant_name` +- نوع: `string` +- توضیح: معادل `crop_id`. + +#### `growth_stage` +- نوع: `string` +- توضیح: مرحله رشد ثبت‌شده. + +#### `irrigation_method_name` +- نوع: `string` +- توضیح: نام روش آبیاری. + +#### `status` +- نوع: `string` +- توضیح: وضعیت recommendation. + +#### `status_label` +- نوع: `string` +- توضیح: متن فارسی وضعیت. + +#### `requested_at` +- نوع: `string(datetime)` +- توضیح: زمان ساخت recommendation. + +--- + +# 7) جزئیات یک recommendation آبیاری + +## GET `/api/irrigation/recommendations/{recommendation_uuid}/` + +این endpoint جزئیات یک recommendation ذخیره‌شده را با همان shape endpoint اصلی recommendation برمی‌گرداند. + +### Path Params + +#### `recommendation_uuid` +- نوع: `string (uuid)` +- اجباری: بله +- توضیح: شناسه recommendation. + +### پاسخ موفق نمونه + +```json +{ + "code": 200, + "msg": "success", + "data": { + "recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11", + "crop_id": "گوجه فرنگی", + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_name": "آبیاری قطره ای", + "status": "completed", + "status_label": "پایان یافته", + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 30 + }, + "water_balance": { + "active_kc": 0.93, + "daily": [] + }, + "timeline": [ + { + "step_number": 1, + "title": "مرحله اول", + "description": "اجرا شود" + } + ], + "sections": [ + { + "type": "tip", + "title": "نکته", + "content": "صبح زود آبیاری شود" + } + ] + } +} +``` + +### خطای عدم وجود recommendation + +```json +{ + "code": 404, + "msg": "Recommendation not found." +} +``` + +--- + + +# 9) پیشنهاد جریان استفاده در فرانت + +برای صفحه recommendation آبیاری، ترتیب پیشنهادی این است: + +1. با `GET /api/irrigation/` لیست روش‌های آبیاری را بگیرید. +2. کاربر یکی از روش‌ها را انتخاب کند. +3. روش انتخاب‌شده را روی مزرعه ذخیره کنید (`irrigation_method_id` و `irrigation_method_name`). +4. با `GET /api/plants/selected/?farm_uuid=...` محصولات انتخاب‌شده مزرعه را بگیرید. +5. کاربر محصول و مرحله رشد را انتخاب کند. +6. `POST /api/irrigation/recommend/` را فقط با `farm_uuid` و `plant_name` و `growth_stage` صدا بزنید. +7. برای history از `GET /api/irrigation/recommendations/` و برای جزئیات از `GET /api/irrigation/recommendations/{recommendation_uuid}/` استفاده کنید. + +--- + +# 10) جمع‌بندی سریع endpointها + +| Method | Path | کاربرد | +|---|---|---| +| GET | `/api/plants/selected/` | گرفتن محصولات انتخاب‌شده مزرعه | +| GET | `/api/irrigation/` | گرفتن لیست روش‌های آبیاری | +| POST | `/api/irrigation/` | ایجاد روش آبیاری جدید در upstream | +| GET | `/api/irrigation/config/` | گرفتن config اولیه صفحه recommendation | +| POST | `/api/irrigation/recommend/` | تولید recommendation آبیاری | +| GET | `/api/irrigation/recommendations/` | گرفتن history recommendationهای آبیاری | +| GET | `/api/irrigation/recommendations/{recommendation_uuid}/` | گرفتن جزئیات یک recommendation | +| POST | `/api/irrigation/water-stress/` | گرفتن شاخص تنش آبی | diff --git a/Modules/Backend/irrigation/IRRIGATION_PLAN_APIS.md b/Modules/Backend/irrigation/IRRIGATION_PLAN_APIS.md new file mode 100644 index 0000000..67bc70f --- /dev/null +++ b/Modules/Backend/irrigation/IRRIGATION_PLAN_APIS.md @@ -0,0 +1,232 @@ +# Irrigation Plan APIs + +این فایل APIهای مدیریت برنامه‌های آبیاری را توضیح می‌دهد. + +Base path: + +`/api/irrigation/` + +این APIها فقط روی برنامه‌های متعلق به کاربر لاگین‌شده عمل می‌کنند. + +--- + +## 1) دریافت لیست برنامه‌های آبیاری + +### Request + +- Method: `GET` +- URL: `/api/irrigation/plans/` +- Query params: + - `farm_uuid` الزامی + - `page` اختیاری + - `page_size` اختیاری، حداکثر `100` + +### Example + +```http +GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10 +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": [ + { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه آبیاری گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": false, + "created_at": "2025-02-24T10:20:30Z" + } + ], + "pagination": { + "page": 1, + "page_size": 10, + "total_pages": 1, + "total_items": 1, + "has_next": false, + "has_previous": false, + "next": null, + "previous": null + } +} +``` + +### Notes + +- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند. +- ترتیب لیست از جدید به قدیم است. +- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد. + +--- + +## 2) دریافت جزئیات یک برنامه آبیاری + +### Request + +- Method: `GET` +- URL: `/api/irrigation/plans/{plan_uuid}/` +- Path param: + - `plan_uuid` الزامی + +### Example + +```http +GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "source": "free_text", + "source_label": "متن آزاد کاربر", + "title": "برنامه آبیاری گندم", + "crop_id": "گندم", + "plant_name": "گندم", + "growth_stage": "flowering", + "is_active": false, + "created_at": "2025-02-24T10:20:30Z", + "updated_at": "2025-02-24T10:20:30Z", + "plan_payload": { + "plan": { + "durationMinutes": 25 + } + } + } +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +### Notes + +- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده می‌شود. + +--- + +## 3) حذف برنامه آبیاری + +### Request + +- Method: `DELETE` +- URL: `/api/irrigation/plans/{plan_uuid}/` + +### Example + +```http +DELETE /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/ +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_deleted": true + } +} +``` + +### Behavior + +- حذف به‌صورت `soft delete` انجام می‌شود. +- در عمل: + - `is_deleted = true` + - `is_active = false` + - `deleted_at` مقداردهی می‌شود + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## 4) تغییر وضعیت فعال بودن برنامه آبیاری + +### Request + +- Method: `PATCH` +- URL: `/api/irrigation/plans/{plan_uuid}/status/` +- Body: + - `is_active` الزامی، `boolean` + +### Example + +```http +PATCH /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/ +Content-Type: application/json + +{ + "is_active": false +} +``` + +### Success Response + +```json +{ + "code": 200, + "msg": "success", + "data": { + "plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1", + "is_active": true + } +} +``` + +### Validation Error + +```json +{ + "is_active": [ + "This field is required." + ] +} +``` + +### Not Found + +```json +{ + "code": 404, + "msg": "Plan not found." +} +``` + +--- + +## Summary + +- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند. +- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد. +- `GET /api/irrigation/plans/` لیست برنامه‌ها +- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه +- `DELETE /api/irrigation/plans/{plan_uuid}/` حذف نرم برنامه +- `PATCH /api/irrigation/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه diff --git a/Modules/Backend/irrigation/__init__.py b/Modules/Backend/irrigation/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/irrigation/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/irrigation/apps.py b/Modules/Backend/irrigation/apps.py new file mode 100644 index 0000000..3033639 --- /dev/null +++ b/Modules/Backend/irrigation/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class IrrigationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "irrigation" + label = "irrigation_recommendation" + verbose_name = "Irrigation Recommendation & Plan Parser" diff --git a/Modules/Backend/irrigation/defaults.py b/Modules/Backend/irrigation/defaults.py new file mode 100644 index 0000000..c98d32d --- /dev/null +++ b/Modules/Backend/irrigation/defaults.py @@ -0,0 +1,28 @@ +CONFIG_RESPONSE_TEMPLATE = { + "farmInfo": { + "soilType": None, + "waterQuality": None, + "climateZone": None, + }, + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], + "status": "success", + "source": "default_template", +} + + +IRRIGATION_DASHBOARD_TEMPLATE = { + "title": "آبیاری", + "subtitle": "داده توصیه آبیاری هنوز ثبت نشده است.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation recommendation is available for this farm."], +} diff --git a/Modules/Backend/irrigation/migrations/0001_initial.py b/Modules/Backend/irrigation/migrations/0001_initial.py new file mode 100644 index 0000000..1ec2175 --- /dev/null +++ b/Modules/Backend/irrigation/migrations/0001_initial.py @@ -0,0 +1,40 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0002_seed_default_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="IrrigationRecommendationRequest", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)), + ("status", models.CharField(blank=True, default="", max_length=64)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="irrigations", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "irrigation_requests", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/irrigation/migrations/0002_recommendation_status_and_growth_stage.py b/Modules/Backend/irrigation/migrations/0002_recommendation_status_and_growth_stage.py new file mode 100644 index 0000000..7f93df4 --- /dev/null +++ b/Modules/Backend/irrigation/migrations/0002_recommendation_status_and_growth_stage.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("irrigation_recommendation", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="irrigationrecommendationrequest", + name="growth_stage", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="irrigationrecommendationrequest", + name="status", + field=models.CharField( + choices=[ + ("in_progress", "در حال اجرا"), + ("pending_confirmation", "منتظر تایید"), + ("completed", "پایان یافته"), + ("error", "خطا"), + ], + db_index=True, + default="pending_confirmation", + max_length=64, + ), + ), + ] diff --git a/Modules/Backend/irrigation/migrations/0003_irrigationplan.py b/Modules/Backend/irrigation/migrations/0003_irrigationplan.py new file mode 100644 index 0000000..0d0e0f7 --- /dev/null +++ b/Modules/Backend/irrigation/migrations/0003_irrigationplan.py @@ -0,0 +1,44 @@ +import django.db.models.deletion +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("irrigation_recommendation", "0002_recommendation_status_and_growth_stage"), + ] + + operations = [ + migrations.CreateModel( + name="IrrigationPlan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)), + ("title", models.CharField(blank=True, default="", max_length=255)), + ("crop_id", models.CharField(blank=True, default="", max_length=255)), + ("growth_stage", models.CharField(blank=True, default="", max_length=255)), + ("plan_payload", models.JSONField(blank=True, default=dict)), + ("request_payload", models.JSONField(blank=True, default=dict)), + ("response_payload", models.JSONField(blank=True, default=dict)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ("is_deleted", models.BooleanField(db_index=True, default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "farm", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="irrigation_plans", to="farm_hub.farmhub"), + ), + ( + "recommendation", + models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="irrigation_recommendation.irrigationrecommendationrequest"), + ), + ], + options={ + "db_table": "irrigation_plans", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/irrigation/migrations/0004_irrigationplan_default_inactive.py b/Modules/Backend/irrigation/migrations/0004_irrigationplan_default_inactive.py new file mode 100644 index 0000000..1865a4a --- /dev/null +++ b/Modules/Backend/irrigation/migrations/0004_irrigationplan_default_inactive.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("irrigation_recommendation", "0003_irrigationplan"), + ] + + operations = [ + migrations.AlterField( + model_name="irrigationplan", + name="is_active", + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/Modules/Backend/irrigation/migrations/__init__.py b/Modules/Backend/irrigation/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/irrigation/mock_data.py b/Modules/Backend/irrigation/mock_data.py new file mode 100644 index 0000000..55fef1d --- /dev/null +++ b/Modules/Backend/irrigation/mock_data.py @@ -0,0 +1,44 @@ +""" +Static mock data for Irrigation Recommendation API. +No database, no dynamic values. +""" + +CONFIG_RESPONSE_DATA = { + "farmInfo": { + "soilType": "Loamy", + "waterQuality": "Medium EC", + "climateZone": "Temperate", + }, + "cropOptions": [ + {"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"}, + {"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"}, + {"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"}, + {"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"}, + {"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"}, + {"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"}, + ], +} + +RECOMMEND_RESPONSE_DATA = { + "plan": { + "frequencyPerWeek": 4, + "durationMinutes": 45, + "bestTimeOfDay": "05:00 - 07:00", + "moistureLevel": 72, + "warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.", + }, +} + +WATER_NEED_PREDICTION = { + "totalNext7Days": 3290, + "unit": "m3", + "categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"], + "series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}], +} + +IRRIGATION_DASHBOARD_RECOMMENDATION = { + "title": "آبیاری: 05:00 - 07:00", + "subtitle": "4 نوبت در هفته، 45 دقیقه برای هر نوبت. رطوبت هدف 72%.", + "avatarIcon": "tabler-droplet", + "avatarColor": "primary", +} diff --git a/Modules/Backend/irrigation/models.py b/Modules/Backend/irrigation/models.py new file mode 100644 index 0000000..17d8456 --- /dev/null +++ b/Modules/Backend/irrigation/models.py @@ -0,0 +1,94 @@ +import uuid + +from django.db import models +from django.utils import timezone + +from farm_hub.models import FarmHub + + +class IrrigationRecommendationRequest(models.Model): + STATUS_IN_PROGRESS = "in_progress" + STATUS_PENDING_CONFIRMATION = "pending_confirmation" + STATUS_COMPLETED = "completed" + STATUS_ERROR = "error" + STATUS_CHOICES = ( + (STATUS_IN_PROGRESS, "در حال اجرا"), + (STATUS_PENDING_CONFIRMATION, "منتظر تایید"), + (STATUS_COMPLETED, "پایان یافته"), + (STATUS_ERROR, "خطا"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="irrigations", + ) + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + task_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + status = models.CharField( + max_length=64, + choices=STATUS_CHOICES, + default=STATUS_PENDING_CONFIRMATION, + db_index=True, + ) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "irrigation_requests" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.task_id or str(self.uuid) + + +class IrrigationPlan(models.Model): + SOURCE_RECOMMENDATION = "recommendation" + SOURCE_FREE_TEXT = "free_text" + SOURCE_CHOICES = ( + (SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"), + (SOURCE_FREE_TEXT, "متن آزاد کاربر"), + ) + + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="irrigation_plans", + ) + source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True) + recommendation = models.ForeignKey( + IrrigationRecommendationRequest, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="plans", + ) + title = models.CharField(max_length=255, blank=True, default="") + crop_id = models.CharField(max_length=255, blank=True, default="") + growth_stage = models.CharField(max_length=255, blank=True, default="") + plan_payload = models.JSONField(default=dict, blank=True) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=False, db_index=True) + is_deleted = models.BooleanField(default=False, db_index=True) + deleted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "irrigation_plans" + ordering = ["-created_at", "-id"] + + def __str__(self): + return self.title or self.crop_id or str(self.uuid) + + def soft_delete(self): + self.is_deleted = True + self.is_active = False + self.deleted_at = timezone.now() + self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"]) diff --git a/Modules/Backend/irrigation/postman/irrigation_recommendation.json b/Modules/Backend/irrigation/postman/irrigation_recommendation.json new file mode 100644 index 0000000..2f0de10 --- /dev/null +++ b/Modules/Backend/irrigation/postman/irrigation_recommendation.json @@ -0,0 +1 @@ +{"info":{"name":"Irrigation Recommendation","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Irrigation Recommendation API. GET config (farm info + crop options). POST recommend (optional body). Returns static plan. No database."},"item":[{"name":"Get config (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/irrigation-recommendation/config/","description":"Returns static farmInfo and cropOptions."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"farmInfo\": {\n \"soilType\": \"Loamy\",\n \"waterQuality\": \"Medium EC\",\n \"climateZone\": \"Temperate\"\n },\n \"cropOptions\": [\n {\"id\": \"wheat\", \"labelKey\": \"wheat\", \"icon\": \"tabler-wheat\"},\n {\"id\": \"corn\", \"labelKey\": \"corn\", \"icon\": \"tabler-plant-2\"},\n {\"id\": \"cotton\", \"labelKey\": \"cotton\", \"icon\": \"tabler-flower\"},\n {\"id\": \"saffron\", \"labelKey\": \"saffron\", \"icon\": \"tabler-flower-2\"},\n {\"id\": \"canola\", \"labelKey\": \"canola\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"vegetables\", \"labelKey\": \"vegetables\", \"icon\": \"tabler-carrot\"}\n ]\n }\n}"}]},{"name":"Get recommendation (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"crop_id\": \"wheat\",\n \"soilType\": \"Loamy\",\n \"waterQuality\": \"Medium EC\",\n \"climateZone\": \"Temperate\"\n}"},"url":"{{baseUrl}}/api/irrigation-recommendation/recommend/","description":"Optional body: crop_id, farm info. Returns static plan. Input not processed."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"plan\": {\n \"frequencyPerWeek\": 4,\n \"durationMinutes\": 45,\n \"bestTimeOfDay\": \"05:00 - 07:00\",\n \"moistureLevel\": 72,\n \"warning\": \"Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.\"\n }\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]} diff --git a/Modules/Backend/irrigation/serializers.py b/Modules/Backend/irrigation/serializers.py new file mode 100644 index 0000000..6a6770b --- /dev/null +++ b/Modules/Backend/irrigation/serializers.py @@ -0,0 +1,155 @@ +from rest_framework import serializers + + +class IrrigationFarmDataSerializer(serializers.Serializer): + soilType = serializers.CharField(required=False, allow_blank=True) + waterQuality = serializers.CharField(required=False, allow_blank=True) + climateZone = serializers.CharField(required=False, allow_blank=True) + + +class IrrigationRecommendRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, help_text="UUID مزرعه برای دریافت توصیه آبیاری.") + sensor_uuid = serializers.UUIDField(required=False, help_text="نام قدیمی farm_uuid برای سازگاری با کلاینت های قدیمی.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.") + growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.") + irrigation_type = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری مورد استفاده در UI.") + irrigation_method_name = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری انتخابی.") + + def validate(self, attrs): + farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid") + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + + attrs["farm_uuid"] = farm_uuid + irrigation_method_name = attrs.get("irrigation_method_name") or attrs.get("irrigation_type") + if irrigation_method_name: + attrs["irrigation_method_name"] = irrigation_method_name + attrs.setdefault("irrigation_type", irrigation_method_name) + + return attrs + + +class WaterStressRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.") + sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور برای فیلتر اختیاری.") + + +class IrrigationMethodSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + name = serializers.CharField(required=False, allow_blank=True) + category = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + water_efficiency_percent = serializers.FloatField(required=False) + water_pressure_required = serializers.CharField(required=False, allow_blank=True) + flow_rate = serializers.CharField(required=False, allow_blank=True) + coverage_area = serializers.CharField(required=False, allow_blank=True) + soil_type = serializers.CharField(required=False, allow_blank=True) + climate_suitability = serializers.CharField(required=False, allow_blank=True) + created_at = serializers.DateTimeField(required=False) + updated_at = serializers.DateTimeField(required=False) + + +class IrrigationRecommendationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های آبیاری.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class IrrigationRecommendationListItemSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + irrigation_method_name = serializers.CharField(read_only=True, allow_blank=True) + status = serializers.CharField(read_only=True) + status_label = serializers.CharField(source="get_status_display", read_only=True) + requested_at = serializers.DateTimeField(source="created_at", read_only=True) + + +class FreeTextPlanParserRequestSerializer(serializers.Serializer): + message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.") + answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.") + partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.") + farm_uuid = serializers.UUIDField( + required=False, + allow_null=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه برای context اختیاری.", + ) + + def validate(self, attrs): + has_message = bool((attrs.get("message") or "").strip()) + has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers")) + has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan")) + if not (has_message or has_answers or has_partial_plan): + raise serializers.ValidationError( + {"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]} + ) + return attrs + + +class PlanParserQuestionSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + field = serializers.CharField(required=False, allow_blank=True) + question = serializers.CharField(required=False, allow_blank=True) + rationale = serializers.CharField(required=False, allow_blank=True) + + +class FreeTextPlanParserResponseDataSerializer(serializers.Serializer): + status = serializers.CharField(required=False, allow_blank=True) + status_fa = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + missing_fields = serializers.ListField(child=serializers.CharField(), required=False) + questions = PlanParserQuestionSerializer(many=True, required=False) + collected_data = serializers.DictField(required=False) + final_plan = serializers.DictField(required=False, allow_null=True) + + +class IrrigationRecommendResponseDataSerializer(serializers.Serializer): + recommendation_uuid = serializers.UUIDField(read_only=True, required=False) + crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True) + plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True) + irrigation_method_name = serializers.CharField(read_only=True, required=False, allow_blank=True) + status = serializers.CharField(read_only=True, required=False) + status_label = serializers.CharField(read_only=True, required=False) + plan = serializers.DictField(read_only=True) + water_balance = serializers.DictField(read_only=True) + timeline = serializers.ListField(child=serializers.DictField(), read_only=True) + sections = serializers.ListField(child=serializers.DictField(), read_only=True) + + +class IrrigationPlanListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های آبیاری.") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class IrrigationPlanListItemSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + + +class IrrigationPlanDetailSerializer(serializers.Serializer): + plan_uuid = serializers.UUIDField(source="uuid", read_only=True) + source = serializers.CharField(read_only=True) + source_label = serializers.CharField(source="get_source_display", read_only=True) + title = serializers.CharField(read_only=True) + crop_id = serializers.CharField(read_only=True) + plant_name = serializers.CharField(source="crop_id", read_only=True) + growth_stage = serializers.CharField(read_only=True) + is_active = serializers.BooleanField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + plan_payload = serializers.DictField(read_only=True) + + +class IrrigationPlanStatusUpdateSerializer(serializers.Serializer): + is_active = serializers.BooleanField(required=True) diff --git a/Modules/Backend/irrigation/services.py b/Modules/Backend/irrigation/services.py new file mode 100644 index 0000000..79d4ca5 --- /dev/null +++ b/Modules/Backend/irrigation/services.py @@ -0,0 +1,325 @@ +from copy import deepcopy +import logging + +from config.failure_contract import StructuredServiceError + +from .defaults import IRRIGATION_DASHBOARD_TEMPLATE +from .models import IrrigationPlan, IrrigationRecommendationRequest + +logger = logging.getLogger(__name__) + + +class IrrigationDataUnavailableError(StructuredServiceError): + def __init__(self, *, error_code: str, message: str, details: dict | None = None): + super().__init__( + error_code=error_code, + message=message, + source="db", + details=details, + ) + + +def _extract_result(response_payload): + if not isinstance(response_payload, dict): + raise IrrigationDataUnavailableError( + error_code="invalid_payload", + message="Irrigation recommendation payload must be a JSON object.", + ) + + data = response_payload.get("data") + if isinstance(data, dict): + if isinstance(data.get("result"), dict): + return data["result"] + if any(key in data for key in ("plan", "water_balance", "timeline", "sections")): + return data + + result = response_payload.get("result") + if isinstance(result, dict): + return result + + if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")): + return response_payload + + return None + + +def _get_latest_result(farm): + if farm is None: + raise IrrigationDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for irrigation result lookup.", + ) + + for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"): + try: + result = _extract_result(request.response_payload) + except IrrigationDataUnavailableError as exc: + logger.error( + "Invalid irrigation response payload for farm_id=%s request_id=%s: %s", + getattr(farm, "id", None), + request.id, + exc, + ) + raise IrrigationDataUnavailableError( + error_code=exc.contract.error_code, + message=f"Invalid irrigation recommendation payload for request_id={request.id}.", + details={"farm_id": getattr(farm, "id", None), "request_id": request.id}, + ) from exc + if result: + return result + + raise IrrigationDataUnavailableError( + error_code="no_data", + message=f"No irrigation recommendation result found for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) + + +def get_active_plan_payload(farm): + if farm is None: + raise IrrigationDataUnavailableError( + error_code="missing_farm", + message="Farm instance is required for active irrigation plan lookup.", + ) + + plan = ( + IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False) + .order_by("-created_at", "-id") + .first() + ) + if plan is None or not isinstance(plan.plan_payload, dict): + raise IrrigationDataUnavailableError( + error_code="no_active_plan", + message=f"No active irrigation plan payload found for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) + + return deepcopy(plan.plan_payload) + + +def build_active_plan_context(farm): + plan_payload = get_active_plan_payload(farm) + + context = {"plan_payload": plan_payload} + + plan = _normalize_plan(plan_payload.get("plan")) + if plan: + context["plan"] = plan + + water_balance = _normalize_water_balance(plan_payload.get("water_balance")) + if water_balance: + context["water_balance"] = water_balance + + timeline = _normalize_timeline(plan_payload.get("timeline")) + if timeline: + context["timeline"] = timeline + + sections = _normalize_sections(plan_payload.get("sections")) + if sections: + context["sections"] = sections + + return context + + +def _normalize_plan(plan): + if not isinstance(plan, dict): + return {} + + normalized = {} + for key in ("frequencyPerWeek", "durationMinutes", "bestTimeOfDay", "moistureLevel", "warning"): + value = plan.get(key) + if value is not None: + normalized[key] = value + return normalized + + +def _normalize_crop_profile(crop_profile): + if not isinstance(crop_profile, dict): + return {} + + normalized = {} + for key in ("kc_initial", "kc_mid", "kc_end"): + value = crop_profile.get(key) + if value is not None: + normalized[key] = value + return normalized + + +def _normalize_daily_entries(daily_entries): + if not isinstance(daily_entries, list): + return [] + + normalized_daily = [] + allowed_keys = ( + "forecast_date", + "et0_mm", + "etc_mm", + "effective_rainfall_mm", + "gross_irrigation_mm", + "irrigation_timing", + ) + for entry in daily_entries: + if not isinstance(entry, dict): + continue + normalized_entry = {key: entry.get(key) for key in allowed_keys if entry.get(key) is not None} + if normalized_entry: + normalized_daily.append(normalized_entry) + + return normalized_daily + + +def _normalize_water_balance(water_balance): + if not isinstance(water_balance, dict): + return {} + + normalized = {} + if water_balance.get("active_kc") is not None: + normalized["active_kc"] = water_balance.get("active_kc") + + crop_profile = _normalize_crop_profile(water_balance.get("crop_profile")) + if crop_profile: + normalized["crop_profile"] = crop_profile + + normalized["daily"] = _normalize_daily_entries(water_balance.get("daily")) + return normalized + + +def _normalize_timeline(timeline): + if not isinstance(timeline, list): + return [] + + normalized_timeline = [] + for item in timeline: + if not isinstance(item, dict): + continue + normalized_item = {} + for key in ("step_number", "title", "description"): + value = item.get(key) + if value is not None: + normalized_item[key] = value + if normalized_item: + normalized_timeline.append(normalized_item) + + return normalized_timeline + + +def _normalize_sections(raw_sections): + if not isinstance(raw_sections, list): + return [] + + allowed_keys = { + "type", + "title", + "icon", + "content", + "items", + "frequency", + "amount", + "timing", + "validityPeriod", + "expandableExplanation", + } + + normalized_sections = [] + for section in raw_sections: + if not isinstance(section, dict) or not section.get("type"): + continue + + normalized_section = {} + for key in allowed_keys: + value = section.get(key) + if value is None: + continue + if key == "items": + if not isinstance(value, list): + continue + normalized_section[key] = [str(item) for item in value] + continue + normalized_section[key] = str(value) if key != "type" else value + + normalized_sections.append(normalized_section) + return normalized_sections + + +def build_recommendation_response(adapter_payload): + result = _extract_result(adapter_payload) + if not isinstance(result, dict): + raise IrrigationDataUnavailableError( + error_code="no_result", + message="Irrigation recommendation payload did not include a result object.", + ) + if not isinstance(result.get("plan"), dict): + raise IrrigationDataUnavailableError( + error_code="invalid_payload", + message="Irrigation recommendation payload is missing a valid `plan` object.", + ) + + response = { + "plan": _normalize_plan(result.get("plan")), + "water_balance": _normalize_water_balance(result.get("water_balance")), + "timeline": _normalize_timeline(result.get("timeline")), + "sections": _normalize_sections(result.get("sections")), + } + return response + + +def get_water_need_prediction_data(farm=None): + result = _get_latest_result(farm) + water_balance = result.get("water_balance", {}) + daily = water_balance.get("daily", []) + + if not daily: + raise IrrigationDataUnavailableError( + error_code="empty_daily_data", + message=f"Water need prediction data is missing daily entries for farm_id={getattr(farm, 'id', None)}.", + details={"farm_id": getattr(farm, "id", None)}, + ) + + categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)] + series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily] + + return { + "totalNext7Days": round(sum(series_data), 2), + "unit": "mm", + "categories": categories, + "series": [{"name": "نیاز آبی", "data": series_data}], + } + + +def get_irrigation_dashboard_recommendation(farm=None): + default_item = deepcopy(IRRIGATION_DASHBOARD_TEMPLATE) + try: + result = _get_latest_result(farm) + except IrrigationDataUnavailableError as exc: + logger.info( + "Irrigation dashboard recommendation unavailable for farm_id=%s: %s", + getattr(farm, "id", None), + exc, + ) + return default_item + plan = result.get("plan") + if not isinstance(plan, dict): + return default_item + + best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00" + frequency = plan.get("frequencyPerWeek") + duration = plan.get("durationMinutes") + moisture = plan.get("moistureLevel") + warning = plan.get("warning") + + subtitle_parts = [] + if frequency is not None and duration is not None: + subtitle_parts.append(f"{frequency} نوبت در هفته، {duration} دقیقه برای هر نوبت") + if moisture is not None: + subtitle_parts.append(f"رطوبت هدف {moisture}%") + if warning: + subtitle_parts.append(str(warning)) + + default_item["title"] = f"آبیاری: {best_time}" + if subtitle_parts: + default_item["subtitle"] = ". ".join(subtitle_parts) + default_item["status"] = "success" + default_item["source"] = "db" + default_item["warnings"] = [] + + return default_item diff --git a/Modules/Backend/irrigation/tests.py b/Modules/Backend/irrigation/tests.py new file mode 100644 index 0000000..26f6010 --- /dev/null +++ b/Modules/Backend/irrigation/tests.py @@ -0,0 +1,734 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from farmer_calendar.models import FarmerCalendarEvent + +from .models import IrrigationPlan, IrrigationRecommendationRequest +from .services import IrrigationDataUnavailableError, build_recommendation_response +from .views import ( + IrrigationMethodListView, + IrrigationPlanDetailView, + IrrigationPlanListView, + IrrigationPlanStatusView, + PlanFromTextView, + RecommendView, + RecommendationDetailView, + RecommendationListView, + WaterStressView, +) + + +class IrrigationServiceFailureTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="irrigation-service-user", + password="secret123", + email="irrigation-service@example.com", + phone_number="09120000009", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Service Farm") + + def test_get_water_need_prediction_raises_structured_error_for_missing_daily_entries(self): + IrrigationRecommendationRequest.objects.create( + farm=self.farm, + response_payload={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}}, + ) + + from .services import get_water_need_prediction_data + + with self.assertRaises(IrrigationDataUnavailableError) as exc_info: + get_water_need_prediction_data(self.farm) + + self.assertEqual(exc_info.exception.contract.error_code, "empty_daily_data") + + def test_build_recommendation_response_rejects_non_object_payload(self): + with self.assertRaises(IrrigationDataUnavailableError) as exc_info: + build_recommendation_response(["not-a-dict"]) + + self.assertEqual(exc_info.exception.contract.error_code, "invalid_payload") + + +class WaterStressViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("irrigation.views.external_api_request") + def test_post_proxies_request_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "waterStressIndex": 12, + "level": "پایین", + "sourceMetric": {"soilMoisture": 24}, + } + } + }, + ) + + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["msg"], "success") + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["waterStressIndex"], 12) + self.assertEqual(response.data["data"]["level"], "پایین") + self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24}) + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation_water_stress") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/water-stress/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_post_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + @patch("irrigation.views.external_api_request") + def test_post_returns_upstream_failure_without_masking_as_empty(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=503, + data={"message": "AI unavailable", "status": "error"}, + ) + + request = self.factory.post( + "/api/irrigation/water-stress/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = WaterStressView.as_view()(request) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.data["data"]["message"], "AI unavailable") + self.assertNotEqual(response.data.get("data"), []) + self.assertNotEqual(response.data.get("data"), {}) + + +class IrrigationPlanFromTextViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="plan-parser-user", + password="secret123", + email="plan-parser@example.com", + phone_number="09120000005", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Plan Parser Farm") + + @patch("irrigation.views.external_api_request") + def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "code": 200, + "msg": "موفق", + "data": { + "status": "completed", + "status_fa": "تکمیل شد", + "summary": "done", + "missing_fields": [], + "questions": [], + "collected_data": {"crop_name": "گوجه فرنگی"}, + "final_plan": {"crop_name": "گوجه فرنگی"}, + }, + }, + ) + + request = self.factory.post( + "/api/irrigation/plan-from-text/", + {"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["status"], "completed") + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertEqual(response.data["meta"]["ownership"], "backend") + self.assertEqual(IrrigationPlan.objects.count(), 1) + plan = IrrigationPlan.objects.get() + self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT) + self.assertEqual(plan.crop_id, "گوجه فرنگی") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/plan-from-text/", + method="POST", + payload={"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_plan_from_text_requires_message_or_answers_or_partial_plan(self): + request = self.factory.post("/api/irrigation/plan-from-text/", {}, format="json") + force_authenticate(request, user=self.user) + + response = PlanFromTextView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + + +class IrrigationMethodListViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + + @patch("irrigation.views.external_api_request") + def test_get_proxies_irrigation_methods_from_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": [ + { + "id": 1, + "name": "Drip", + "category": "micro", + "description": "Efficient irrigation", + "water_efficiency_percent": 90.0, + } + ] + }, + ) + + request = self.factory.get("/api/irrigation/") + response = IrrigationMethodListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"][0]["name"], "Drip") + self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy") + self.assertTrue(response.data["meta"]["live"]) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/", + method="GET", + ) + + @patch("irrigation.views.external_api_request") + def test_post_proxies_irrigation_method_creation_to_ai(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=201, + data={ + "data": { + "id": 1, + "name": "Drip", + "category": "micro", + } + }, + ) + + request = self.factory.post("/api/irrigation/", {"name": "Drip"}, format="json") + response = IrrigationMethodListView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["data"]["name"], "Drip") + self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/", + method="POST", + payload={"name": "Drip"}, + ) + + +class RecommendViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="recommend-farmer", + password="secret123", + email="recommend@example.com", + phone_number="09120000002", + ) + self.farm_type = FarmType.objects.create(name="باغی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Recommend Farm", + irrigation_method_id=3, + irrigation_method_name="آبیاری قطره ای", + ) + + @patch("irrigation.views.external_api_request") + def test_post_returns_full_recommendation_shape(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "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": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند", + }, + ], + } + } + }, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertIn("recommendation_uuid", response.data["data"]) + self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION) + self.assertEqual(response.data["data"]["status_label"], "منتظر تایید") + self.assertEqual(IrrigationPlan.objects.count(), 1) + plan = IrrigationPlan.objects.get() + self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION) + self.assertFalse(plan.is_active) + self.assertFalse(plan.is_deleted) + self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38) + self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93) + self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "warning") + + @patch("irrigation.views.external_api_request") + def test_recommend_view_persists_real_response_and_never_returns_fake_success_on_invalid_payload(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}}, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 502) + self.assertEqual(IrrigationRecommendationRequest.objects.count(), 1) + self.assertEqual(IrrigationRecommendationRequest.objects.get().status, IrrigationRecommendationRequest.STATUS_ERROR) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه فرنگی", + "growth_stage": "گلدهی", + "irrigation_method_id": 3, + "irrigation_type": "آبیاری قطره ای", + "irrigation_method_name": "آبیاری قطره ای", + }, + ) + + @patch("irrigation.views.external_api_request") + def test_post_includes_active_irrigation_plan_in_ai_payload(self, mock_external_api_request): + IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه فعال", + crop_id="گوجه فرنگی", + growth_stage="گلدهی", + plan_payload={ + "plan": {"frequencyPerWeek": 2, "durationMinutes": 25, "bestTimeOfDay": "صبح"}, + "water_balance": {"active_kc": 0.82, "daily": []}, + "timeline": [{"step_number": 1, "title": "مرحله", "description": "توضیح"}], + "sections": [{"type": "warning", "title": "هشدار", "content": "متن"}], + }, + is_active=True, + ) + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {"plan": {}}}}) + + request = self.factory.post( + "/api/irrigation/recommend/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه فرنگی", "growth_stage": "گلدهی"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertIn("active_irrigation_plan", sent_payload) + self.assertEqual(sent_payload["active_irrigation_plan"]["plan"]["durationMinutes"], 25) + self.assertEqual(sent_payload["active_irrigation_plan"]["water_balance"]["active_kc"], 0.82) + + +class IrrigationRecommendationHistoryTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="history-farmer", + password="secret123", + email="history@example.com", + phone_number="09120000003", + ) + self.other_user = get_user_model().objects.create_user( + username="other-history-farmer", + password="secret123", + email="other-history@example.com", + phone_number="09120000004", + ) + self.farm_type = FarmType.objects.create(name="گلخانه ای") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="History Farm") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Other History Farm") + + def test_recommendation_list_returns_paginated_items(self): + first = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گندم", + growth_stage="vegetative", + status=IrrigationRecommendationRequest.STATUS_COMPLETED, + request_payload={"irrigation_method_name": "بارانی"}, + response_payload={"data": {"plan": {"durationMinutes": 20}}}, + ) + second = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="ذرت", + growth_stage="flowering", + status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + request_payload={"irrigation_method_name": "قطره ای"}, + response_payload={"data": {"plan": {"durationMinutes": 35}}}, + ) + + request = self.factory.get( + f"/api/irrigation/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1" + ) + force_authenticate(request, user=self.user) + + response = RecommendationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["pagination"]["total_items"], 2) + self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid)) + self.assertEqual(response.data["data"][0]["plant_name"], "ذرت") + self.assertEqual(response.data["data"][0]["growth_stage"], "flowering") + self.assertEqual(response.data["data"][0]["irrigation_method_name"], "قطره ای") + self.assertEqual(response.data["data"][0]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION) + self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید") + self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid)) + + def test_recommendation_detail_returns_saved_shape(self): + recommendation = IrrigationRecommendationRequest.objects.create( + farm=self.farm, + crop_id="گوجه فرنگی", + growth_stage="fruiting", + status=IrrigationRecommendationRequest.STATUS_COMPLETED, + request_payload={"irrigation_method_name": "قطره ای"}, + response_payload={ + "data": { + "result": { + "plan": {"frequencyPerWeek": 4, "durationMinutes": 30}, + "water_balance": {"active_kc": 0.93, "daily": []}, + "timeline": [{"step_number": 1, "title": "مرحله اول", "description": "اجرا شود"}], + "sections": [{"type": "tip", "title": "نکته", "content": "صبح زود آبیاری شود"}], + } + } + }, + ) + + request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid)) + self.assertEqual(response.data["data"]["crop_id"], "گوجه فرنگی") + self.assertEqual(response.data["data"]["plant_name"], "گوجه فرنگی") + self.assertEqual(response.data["data"]["growth_stage"], "fruiting") + self.assertEqual(response.data["data"]["irrigation_method_name"], "قطره ای") + self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_COMPLETED) + self.assertEqual(response.data["data"]["status_label"], "پایان یافته") + self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 30) + self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1) + self.assertEqual(response.data["data"]["sections"][0]["type"], "tip") + + def test_recommendation_detail_rejects_foreign_recommendation(self): + recommendation = IrrigationRecommendationRequest.objects.create( + farm=self.other_farm, + crop_id="خیار", + status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION, + ) + + request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/") + force_authenticate(request, user=self.user) + + response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Recommendation not found.") + + @patch("irrigation.views.external_api_request") + def test_post_accepts_sensor_uuid_as_farm_uuid_alias(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"sections": []}}}, + ) + + request = self.factory.post( + "/api/irrigation/recommend/", + { + "sensor_uuid": str(self.farm.farm_uuid), + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RecommendView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["plan"]["frequencyPerWeek"], 4) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/irrigation/recommend/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_method_id": 3, + "irrigation_type": "آبیاری قطره ای", + "irrigation_method_name": "آبیاری قطره ای", + }, + ) + + +class IrrigationPlanApiTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="irrigation-plan-user", + password="secret123", + email="irrigation-plan@example.com", + phone_number="09124445566", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Irrigation Plan Farm") + self.plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری نمونه", + crop_id="گندم", + growth_stage="flowering", + plan_payload={"plan": {"durationMinutes": 25}}, + ) + + def test_plan_list_returns_non_deleted_plans(self): + IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_RECOMMENDATION, + title="حذف شده", + is_deleted=True, + is_active=False, + ) + + request = self.factory.get(f"/api/irrigation/plans/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = IrrigationPlanListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid)) + + def test_plan_detail_returns_plan_payload(self): + request = self.factory.get(f"/api/irrigation/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid)) + self.assertEqual(response.data["data"]["plan_payload"]["plan"]["durationMinutes"], 25) + + def test_plan_delete_is_soft_delete(self): + request = self.factory.delete(f"/api/irrigation/plans/{self.plan.uuid}/") + force_authenticate(request, user=self.user) + + response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertTrue(self.plan.is_deleted) + self.assertFalse(self.plan.is_active) + + def test_plan_status_patch_updates_is_active(self): + request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + + def test_activating_one_plan_deactivates_other_active_plan(self): + other_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه دوم", + is_active=True, + ) + + request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid) + + self.assertEqual(response.status_code, 200) + self.plan.refresh_from_db() + other_plan.refresh_from_db() + self.assertTrue(self.plan.is_active) + self.assertFalse(other_plan.is_active) + + def test_plan_status_patch_syncs_calendar_events(self): + self.plan.plan_payload = { + "plan": {"durationMinutes": 25, "bestTimeOfDay": "05:30 - 06:00"}, + "water_balance": { + "daily": [ + { + "forecast_date": "2025-02-12", + "gross_irrigation_mm": 17, + "irrigation_timing": "05:30 - 06:00", + } + ] + }, + } + self.plan.is_active = False + self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"]) + + activate_request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": True}, + format="json", + ) + force_authenticate(activate_request, user=self.user) + + activate_response = IrrigationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(activate_response.status_code, 200) + events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)) + self.assertEqual(events.count(), 1) + self.assertEqual(events.first().extended_props["plan_type"], "irrigation") + + deactivate_request = self.factory.patch( + f"/api/irrigation/plans/{self.plan.uuid}/status/", + {"is_active": False}, + format="json", + ) + force_authenticate(deactivate_request, user=self.user) + + deactivate_response = IrrigationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid) + + self.assertEqual(deactivate_response.status_code, 200) + self.assertFalse( + FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists() + ) diff --git a/Modules/Backend/irrigation/urls.py b/Modules/Backend/irrigation/urls.py new file mode 100644 index 0000000..acb3fcb --- /dev/null +++ b/Modules/Backend/irrigation/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from .views import ( + ConfigView, + IrrigationMethodListView, + IrrigationPlanDetailView, + IrrigationPlanListView, + IrrigationPlanStatusView, + PlanFromTextView, + RecommendationDetailView, + RecommendationListView, + RecommendView, + WaterStressView, +) + +urlpatterns = [ + path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"), + path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"), + path("plans/", IrrigationPlanListView.as_view(), name="irrigation-plan-list"), + path("plans//", IrrigationPlanDetailView.as_view(), name="irrigation-plan-detail"), + path("plans//status/", IrrigationPlanStatusView.as_view(), name="irrigation-plan-status"), + path("recommendations//", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"), + path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"), + path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"), + path("plan-from-text/", PlanFromTextView.as_view(), name="irrigation-plan-from-text"), + path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"), +] diff --git a/Modules/Backend/irrigation/views.py b/Modules/Backend/irrigation/views.py new file mode 100644 index 0000000..ecff911 --- /dev/null +++ b/Modules/Backend/irrigation/views.py @@ -0,0 +1,667 @@ +""" +Irrigation Recommendation API views. +""" + +import logging + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from config.integration_contract import build_integration_meta +from config.swagger import code_response, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events +from water.serializers import WaterStressIndexSerializer +from water.views import WaterStressIndexView +from .defaults import CONFIG_RESPONSE_TEMPLATE +from .models import IrrigationPlan, IrrigationRecommendationRequest +from .serializers import ( + FreeTextPlanParserRequestSerializer, + FreeTextPlanParserResponseDataSerializer, + IrrigationMethodSerializer, + IrrigationPlanDetailSerializer, + IrrigationPlanListItemSerializer, + IrrigationPlanListQuerySerializer, + IrrigationPlanStatusUpdateSerializer, + IrrigationRecommendationListItemSerializer, + IrrigationRecommendationListQuerySerializer, + IrrigationRecommendRequestSerializer, + IrrigationRecommendResponseDataSerializer, + WaterStressRequestSerializer, +) +from .services import build_recommendation_response +from .services import build_active_plan_context +from .services import IrrigationDataUnavailableError + + +logger = logging.getLogger(__name__) + + +class IrrigationRecommendationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 + + def get_paginated_response(self, data): + page_size = self.get_page_size(self.request) or self.page.paginator.per_page + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "pagination": { + "page": self.page.number, + "page_size": page_size, + "total_pages": self.page.paginator.num_pages, + "total_items": self.page.paginator.count, + "has_next": self.page.has_next(), + "has_previous": self.page.has_previous(), + "next": self.get_next_link(), + "previous": self.get_previous_link(), + }, + }, + status=status.HTTP_200_OK, + ) + + +class FarmAccessMixin: + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class ConfigView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())}, + ) + def get(self, request): + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + data = dict(CONFIG_RESPONSE_TEMPLATE) + data["farm_uuid"] = str(farm.farm_uuid) + return Response({"status": "success", "data": data}, status=status.HTTP_200_OK) + + +class IrrigationMethodListView(APIView): + @staticmethod + def _extract_methods(adapter_data): + if not isinstance(adapter_data, dict): + return adapter_data if isinstance(adapter_data, list) else [] + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), list): + return data["result"] + if isinstance(data, list): + return data + + result = adapter_data.get("result") + if isinstance(result, list): + return result + + return [] + + @extend_schema( + tags=["Irrigation Recommendation"], + responses={200: status_response("IrrigationMethodListResponse", data=IrrigationMethodSerializer(many=True))}, + ) + def get(self, request): + adapter_response = external_api_request( + "ai", + "/api/irrigation/", + method="GET", + ) + + if adapter_response.status_code >= 400: + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + { + "code": 200, + "msg": "success", + "data": self._extract_methods(adapter_response.data), + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation Recommendation"], + request=serializers.JSONField, + responses={201: status_response("IrrigationMethodCreateResponse", data=IrrigationMethodSerializer())}, + ) + def post(self, request): + adapter_response = external_api_request( + "ai", + "/api/irrigation/", + method="POST", + payload=request.data, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + payload = self._extract_methods(adapter_response.data) + if not payload: + payload = response_data.get("data", response_data) + + return Response( + { + "code": adapter_response.status_code, + "msg": "success", + "data": payload, + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation", + ownership="ai", + live=True, + cached=False, + ), + }, + status=adapter_response.status_code, + ) + + +class RecommendView(FarmAccessMixin, APIView): + @staticmethod + def _build_plan_title(crop_id, growth_stage, plan): + best_time = "" + if isinstance(plan, dict): + best_time = str(plan.get("bestTimeOfDay") or "").strip() + parts = [part for part in [crop_id, growth_stage, best_time] if part] + return " - ".join(parts) if parts else "برنامه آبیاری" + + def _create_plan_from_recommendation(self, recommendation, recommendation_data): + plan = IrrigationPlan.objects.create( + farm=recommendation.farm, + source=IrrigationPlan.SOURCE_RECOMMENDATION, + recommendation=recommendation, + title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, recommendation_data.get("plan")), + crop_id=recommendation.crop_id, + growth_stage=recommendation.growth_stage, + plan_payload=recommendation_data, + request_payload=recommendation.request_payload, + response_payload=recommendation.response_payload, + ) + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) + + @staticmethod + def _enrich_ai_payload(payload, farm): + enriched_payload = payload.copy() + try: + active_plan_context = build_active_plan_context(farm) + except IrrigationDataUnavailableError: + active_plan_context = None + if active_plan_context: + enriched_payload["active_irrigation_plan"] = active_plan_context + return enriched_payload + + @extend_schema( + tags=["Irrigation Recommendation"], + request=IrrigationRecommendRequestSerializer, + responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())}, + ) + def post(self, request): + serializer = IrrigationRecommendRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + farm = self._get_farm(request, payload.get("farm_uuid")) + payload["farm_uuid"] = str(farm.farm_uuid) + payload.pop("sensor_uuid", None) + payload.pop("irrigation_type", None) + payload.pop("irrigation_method_name", None) + + if farm.irrigation_method_name: + payload["irrigation_method_name"] = farm.irrigation_method_name + payload["irrigation_type"] = farm.irrigation_method_name + if farm.irrigation_method_id is not None: + payload["irrigation_method_id"] = farm.irrigation_method_id + + ai_payload = self._enrich_ai_payload(payload, farm) + + adapter_response = external_api_request( + "ai", + "/api/irrigation/recommend/", + method="POST", + payload=ai_payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + recommendation = IrrigationRecommendationRequest.objects.create( + farm=farm, + crop_id=payload.get("plant_name", ""), + growth_stage=payload.get("growth_stage", ""), + task_id="", + status=( + IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION + if adapter_response.status_code < 400 + else IrrigationRecommendationRequest.STATUS_ERROR + ), + request_payload=ai_payload, + response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data}, + ) + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) + try: + recommendation_data = build_recommendation_response(response_data) + except IrrigationDataUnavailableError as exc: + recommendation.status = IrrigationRecommendationRequest.STATUS_ERROR + recommendation.save(update_fields=["status"]) + return Response( + {"code": 502, "msg": "error", "data": {"detail": str(exc)}}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + logger.warning( + "Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s", + str(farm.farm_uuid), + adapter_response.status_code, + sorted(response_data.keys()) if isinstance(response_data, dict) else None, + len(recommendation_data["sections"]), + ) + + self._create_plan_from_recommendation(recommendation, recommendation_data) + + recommendation_data["recommendation_uuid"] = str(recommendation.uuid) + recommendation_data["crop_id"] = recommendation.crop_id + recommendation_data["plant_name"] = recommendation.crop_id + recommendation_data["growth_stage"] = recommendation.growth_stage + recommendation_data["irrigation_method_name"] = payload.get("irrigation_method_name", "") + recommendation_data["status"] = recommendation.status + recommendation_data["status_label"] = recommendation.get_status_display() + + return Response( + { + "code": 200, + "msg": "success", + "data": recommendation_data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="provider", + source_service="ai_irrigation", + ownership="backend", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class RecommendationListView(FarmAccessMixin, APIView): + pagination_class = IrrigationRecommendationPagination + + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[IrrigationRecommendationListQuerySerializer], + responses={200: code_response("IrrigationRecommendationListResponse")}, + ) + def get(self, request): + serializer = IrrigationRecommendationListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + recommendations = farm.irrigations.all().order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(recommendations, request, view=self) + + items = [] + for recommendation in page: + request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {} + recommendation.irrigation_method_name = str(request_payload.get("irrigation_method_name") or "") + items.append(recommendation) + + data = IrrigationRecommendationListItemSerializer(items, many=True).data + return paginator.get_paginated_response(data) + + +class RecommendationDetailView(FarmAccessMixin, APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[ + OpenApiParameter( + name="recommendation_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + 200: code_response("IrrigationRecommendationDetailResponse", data=IrrigationRecommendResponseDataSerializer()), + 404: code_response("IrrigationRecommendationDetailNotFoundResponse"), + }, + ) + def get(self, request, recommendation_uuid): + recommendation = IrrigationRecommendationRequest.objects.filter( + uuid=recommendation_uuid, + farm__owner=request.user, + ).select_related("farm").first() + if recommendation is None: + return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND) + + try: + data = build_recommendation_response(recommendation.response_payload) + except IrrigationDataUnavailableError as exc: + return Response( + {"code": 502, "msg": "error", "data": {"detail": str(exc)}}, + status=status.HTTP_502_BAD_GATEWAY, + ) + request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {} + data["recommendation_uuid"] = str(recommendation.uuid) + data["crop_id"] = recommendation.crop_id + data["plant_name"] = recommendation.crop_id + data["growth_stage"] = recommendation.growth_stage + data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "") + data["status"] = recommendation.status + data["status_label"] = recommendation.get_status_display() + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="backend_irrigation", + ownership="backend", + live=False, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class WaterStressView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @extend_schema( + tags=["Irrigation Recommendation"], + request=WaterStressRequestSerializer, + responses={200: status_response("WaterStressResponse", data=WaterStressIndexSerializer())}, + ) + def post(self, request): + serializer = WaterStressRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + query = {"farm_uuid": str(farm.farm_uuid)} + sensor_uuid = payload.get("sensor_uuid") + if sensor_uuid: + query["sensor_uuid"] = str(sensor_uuid) + + adapter_response = external_api_request( + "ai", + "/api/irrigation/water-stress/", + method="POST", + payload=query, + ) + + if adapter_response.status_code >= 400: + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid) + return Response( + { + "code": 200, + "msg": "success", + "data": stress_payload, + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation_water_stress", + ownership="ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class PlanFromTextView(FarmAccessMixin, APIView): + @staticmethod + def _extract_final_plan(response_data): + if not isinstance(response_data, dict): + return None + data = response_data.get("data") + if isinstance(data, dict): + final_plan = data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + final_plan = response_data.get("final_plan") + if isinstance(final_plan, dict) and final_plan: + return final_plan + return None + + @staticmethod + def _build_free_text_plan_title(final_plan): + if not isinstance(final_plan, dict): + return "برنامه آبیاری" + for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"): + value = str(final_plan.get(key, "")).strip() + if value: + return value + return "برنامه آبیاری" + + @extend_schema( + tags=["Irrigation Recommendation"], + request=FreeTextPlanParserRequestSerializer, + responses={200: code_response("IrrigationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())}, + ) + def post(self, request): + serializer = FreeTextPlanParserRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm_uuid = payload.get("farm_uuid") + if farm_uuid: + farm = self._get_farm(request, farm_uuid) + payload["farm_uuid"] = str(farm.farm_uuid) + + adapter_response = external_api_request( + "ai", + "/api/irrigation/plan-from-text/", + method="POST", + payload=payload, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)} + if adapter_response.status_code >= 400: + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + final_plan = self._extract_final_plan(response_data) + if final_plan and farm_uuid: + plan = IrrigationPlan.objects.create( + farm=farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title=self._build_free_text_plan_title(final_plan), + crop_id=str( + final_plan.get("crop_id") + or final_plan.get("crop_name") + or final_plan.get("plant_name") + or "" + ).strip(), + growth_stage=str(final_plan.get("growth_stage") or "").strip(), + plan_payload=final_plan, + request_payload=payload, + response_payload=response_data, + ) + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) + + return Response( + { + "code": 200, + "msg": response_data.get("msg", "موفق"), + "data": response_data.get("data", response_data), + "meta": build_integration_meta( + flow_type="direct_proxy", + source_type="provider", + source_service="ai_irrigation_plan_parser", + ownership="backend" if final_plan and farm_uuid else "ai", + live=True, + cached=False, + ), + }, + status=status.HTTP_200_OK, + ) + + +class IrrigationPlanListView(FarmAccessMixin, APIView): + pagination_class = IrrigationRecommendationPagination + + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[IrrigationPlanListQuerySerializer], + responses={200: code_response("IrrigationPlanListResponse")}, + ) + def get(self, request): + serializer = IrrigationPlanListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = self._get_farm(request, serializer.validated_data["farm_uuid"]) + plans = farm.irrigation_plans.filter(is_deleted=False).order_by("-created_at", "-id") + + paginator = self.pagination_class() + page = paginator.paginate_queryset(plans, request, view=self) + data = IrrigationPlanListItemSerializer(page, many=True).data + return paginator.get_paginated_response(data) + + +class IrrigationPlanDetailView(APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + parameters=[ + OpenApiParameter( + name="plan_uuid", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={200: code_response("IrrigationPlanDetailResponse", data=IrrigationPlanDetailSerializer())}, + ) + def get(self, request, plan_uuid): + plan = IrrigationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).select_related("farm").first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + data = IrrigationPlanDetailSerializer(plan).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + @extend_schema( + tags=["Irrigation Recommendation"], + responses={200: status_response("IrrigationPlanDeleteResponse", data=serializers.JSONField())}, + ) + def delete(self, request, plan_uuid): + plan = IrrigationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + plan.soft_delete() + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid) + return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK) + + +class IrrigationPlanStatusView(APIView): + @extend_schema( + tags=["Irrigation Recommendation"], + request=IrrigationPlanStatusUpdateSerializer, + responses={200: code_response("IrrigationPlanStatusResponse", data=serializers.JSONField())}, + ) + def patch(self, request, plan_uuid): + serializer = IrrigationPlanStatusUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + plan = IrrigationPlan.objects.filter( + uuid=plan_uuid, + farm__owner=request.user, + is_deleted=False, + ).first() + if plan is None: + return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND) + + new_is_active = serializer.validated_data["is_active"] + if new_is_active: + IrrigationPlan.objects.filter( + farm=plan.farm, + is_deleted=False, + is_active=True, + ).exclude(pk=plan.pk).update(is_active=False) + + plan.is_active = new_is_active + plan.save(update_fields=["is_active", "updated_at"]) + if plan.is_active: + sync_plan_events(plan, PLAN_TYPE_IRRIGATION) + else: + delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid) + return Response( + {"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/manage.py b/Modules/Backend/manage.py new file mode 100644 index 0000000..d28672e --- /dev/null +++ b/Modules/Backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/Modules/Backend/notifications/NOTIFICATION_API_CHANGES.md b/Modules/Backend/notifications/NOTIFICATION_API_CHANGES.md new file mode 100644 index 0000000..de7aef4 --- /dev/null +++ b/Modules/Backend/notifications/NOTIFICATION_API_CHANGES.md @@ -0,0 +1,80 @@ +# Notification API Changes + +## Added paginated notification list API + +A new endpoint was added to return all notifications for a farm using pagination. + +### Endpoint + +`GET /api/notifications/list/` + +### Query params + +- `farm_uuid` (required): UUID of the farm +- `page` (optional): page number, default depends on DRF pagination behavior +- `page_size` (optional): number of items per page, default `10`, max `100` + +### Behavior + +- Requires authenticated user +- Returns notifications only if the farm belongs to the authenticated user +- Orders notifications by newest first using `created_at DESC, id DESC` +- Returns paginated response +- Returns `404` if the farm is not found or does not belong to the user + +### Response shape + +```json +{ + "count": 12, + "next": "http://localhost:8000/api/notifications/list/?farm_uuid=&page=2&page_size=5", + "previous": null, + "results": { + "code": 200, + "msg": "success", + "data": [ + { + "uuid": "...", + "farm_uuid": "...", + "since_id": 12, + "title": "Alert", + "message": "Check sensor", + "level": "info", + "is_read": false, + "metadata": {}, + "created_at": "2025-01-01T10:00:00Z" + } + ] + } +} +``` + +## Long-poll behavior update + +The `long-poll` notification logic was updated to prioritize unread notifications. + +### Updated behavior + +- Returns unread notifications first +- If unread notifications are fewer than `5`, fills the remaining slots with read notifications +- If unread notifications are `5` or more, returns only the first `5` unread notifications + +### Notes + +This behavior was implemented in the notification service layer so the existing long-poll endpoint automatically uses it. + +## Files changed + +- `notifications/views.py` +- `notifications/urls.py` +- `notifications/services.py` +- `notifications/tests.py` + +## Tests added + +Added tests for: + +- paginated notification list for owned farm +- `404` for unowned farm on list API +- unread-first ordering in long-poll +- long-poll fallback with read notifications when unread count is below `5` diff --git a/Modules/Backend/notifications/__init__.py b/Modules/Backend/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/notifications/apps.py b/Modules/Backend/notifications/apps.py new file mode 100644 index 0000000..ef841d8 --- /dev/null +++ b/Modules/Backend/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + verbose_name = "Notifications" diff --git a/Modules/Backend/notifications/migrations/0001_initial.py b/Modules/Backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..7bc4cb8 --- /dev/null +++ b/Modules/Backend/notifications/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.1.7 on 2025-02-20 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0006_seed_expanded_product_catalog"), + ] + + operations = [ + migrations.CreateModel( + name="FarmNotification", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("title", models.CharField(max_length=255)), + ("message", models.TextField()), + ("level", models.CharField(default="info", max_length=32)), + ("is_read", models.BooleanField(default=False)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "farm", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "farm_notifications", + "ordering": ["-created_at", "-id"], + }, + ), + ] diff --git a/Modules/Backend/notifications/migrations/0002_farmnotification_tracker_fields.py b/Modules/Backend/notifications/migrations/0002_farmnotification_tracker_fields.py new file mode 100644 index 0000000..3fbb989 --- /dev/null +++ b/Modules/Backend/notifications/migrations/0002_farmnotification_tracker_fields.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.15 on 2026-04-28 23:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="farmnotification", + name="endpoint", + field=models.CharField(blank=True, default="", max_length=64), + ), + migrations.AddField( + model_name="farmnotification", + name="payload", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name="farmnotification", + name="source_alert_id", + field=models.CharField(blank=True, db_index=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmnotification", + name="source_metric_type", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AddField( + model_name="farmnotification", + name="suggested_action", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="farmnotification", + name="updated_at", + field=models.DateTimeField(auto_now=True, default=None), + preserve_default=False, + ), + ] diff --git a/Modules/Backend/notifications/migrations/__init__.py b/Modules/Backend/notifications/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/notifications/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/notifications/models.py b/Modules/Backend/notifications/models.py new file mode 100644 index 0000000..955788c --- /dev/null +++ b/Modules/Backend/notifications/models.py @@ -0,0 +1,31 @@ +import uuid as uuid_lib + +from django.db import models + + +class FarmNotification(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + "farm_hub.FarmHub", + on_delete=models.CASCADE, + related_name="notifications", + ) + title = models.CharField(max_length=255) + message = models.TextField() + level = models.CharField(max_length=32, default="info") + endpoint = models.CharField(max_length=64, blank=True, default="") + suggested_action = models.TextField(blank=True, default="") + source_alert_id = models.CharField(max_length=255, blank=True, default="", db_index=True) + source_metric_type = models.CharField(max_length=255, blank=True, default="") + payload = models.JSONField(default=dict, blank=True) + is_read = models.BooleanField(default=False) + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "farm_notifications" + ordering = ["-created_at", "-id"] + + def __str__(self): + return f"{self.farm_id}:{self.title}" diff --git a/Modules/Backend/notifications/serializers.py b/Modules/Backend/notifications/serializers.py new file mode 100644 index 0000000..ab8663c --- /dev/null +++ b/Modules/Backend/notifications/serializers.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from .models import FarmNotification + + +class FarmNotificationSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(read_only=True) + farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True) + since_id = serializers.IntegerField(source="id", read_only=True) + + class Meta: + model = FarmNotification + fields = [ + "id", + "uuid", + "farm_uuid", + "since_id", + "endpoint", + "title", + "message", + "level", + "suggested_action", + "source_alert_id", + "source_metric_type", + "payload", + "is_read", + "metadata", + "created_at", + "updated_at", + ] diff --git a/Modules/Backend/notifications/services.py b/Modules/Backend/notifications/services.py new file mode 100644 index 0000000..184f7b7 --- /dev/null +++ b/Modules/Backend/notifications/services.py @@ -0,0 +1,112 @@ +import time + +from django.db import OperationalError, ProgrammingError +from django.db.models import Case, IntegerField, QuerySet, Value, When +from django.utils import timezone + + +from farm_hub.models import FarmHub + +from .models import FarmNotification + + +DEFAULT_POLL_TIMEOUT_SECONDS = 15 +DEFAULT_POLL_INTERVAL_SECONDS = 1 + + +def create_notification_for_farm_uuid( + *, + farm_uuid, + title, + message, + level="info", + endpoint="", + suggested_action="", + source_alert_id="", + source_metric_type="", + payload=None, + metadata=None, +): + farm = FarmHub.objects.filter(farm_uuid=farm_uuid).first() + if farm is None: + raise ValueError("Farm not found.") + + try: + return FarmNotification.objects.create( + farm=farm, + title=title, + message=message, + level=level, + endpoint=endpoint, + suggested_action=suggested_action, + source_alert_id=source_alert_id, + source_metric_type=source_metric_type, + payload=payload or {}, + metadata=metadata or {}, + ) + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + +def get_recent_notifications_for_farm(*, farm: FarmHub, since_days=3, limit=10) -> QuerySet[FarmNotification]: + try: + since = timezone.now() - timezone.timedelta(days=max(since_days, 0)) + return FarmNotification.objects.filter(farm=farm, created_at__gte=since).order_by("-created_at", "-id")[:limit] + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + +def get_notifications_for_farm(*, farm: FarmHub, since_id=None) -> QuerySet[FarmNotification]: + try: + queryset = FarmNotification.objects.filter(farm=farm) + if since_id is not None: + queryset = queryset.filter(id__gt=since_id) + return queryset.order_by("created_at", "id") + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + +def get_prioritized_notifications_for_farm(*, farm: FarmHub, since_id=None, limit=5) -> QuerySet[FarmNotification]: + try: + unread_queryset = get_notifications_for_farm(farm=farm, since_id=since_id).filter(is_read=False) + unread_count = unread_queryset.count() + + if unread_count >= limit: + return unread_queryset[:limit] + + fallback_limit = max(limit - unread_count, 0) + if fallback_limit == 0: + return unread_queryset + + queryset = get_notifications_for_farm(farm=farm, since_id=since_id).annotate( + priority=Case( + When(is_read=False, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ) + ) + return queryset.order_by("priority", "created_at", "id")[:limit] + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + +def mark_notifications_as_read(*, farm: FarmHub, slice_id: int) -> int: + try: + return FarmNotification.objects.filter( + farm=farm, + id__lte=slice_id, + is_read=False, + ).update(is_read=True) + except (ProgrammingError, OperationalError) as exc: + raise ValueError("Notifications table is not migrated.") from exc + + +def long_poll_notifications(*, farm: FarmHub, since_id=None, timeout_seconds=DEFAULT_POLL_TIMEOUT_SECONDS, interval_seconds=DEFAULT_POLL_INTERVAL_SECONDS, limit=5): + deadline = time.monotonic() + max(timeout_seconds, 0) + while True: + notifications = list(get_prioritized_notifications_for_farm(farm=farm, since_id=since_id, limit=limit)) + if notifications: + return notifications + if time.monotonic() >= deadline: + return [] + time.sleep(max(interval_seconds, 0)) diff --git a/Modules/Backend/notifications/tests.py b/Modules/Backend/notifications/tests.py new file mode 100644 index 0000000..0eb174d --- /dev/null +++ b/Modules/Backend/notifications/tests.py @@ -0,0 +1,353 @@ +from unittest.mock import patch + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory, force_authenticate + +from farm_hub.models import FarmHub, FarmType + +from .models import FarmNotification +from .services import create_notification_for_farm_uuid, get_prioritized_notifications_for_farm, long_poll_notifications, mark_notifications_as_read +from .views import ExternalNotificationIngestView, NotificationListView, NotificationLongPollView, NotificationMarkReadView + + +class NotificationServiceTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="notif-service-user", + password="secret123", + email="notif-service@example.com", + phone_number="09120000011", + ) + self.farm_type = FarmType.objects.create(name="گلخانه") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm A", + ) + + def test_create_notification_for_farm_uuid_creates_record(self): + notification = create_notification_for_farm_uuid( + farm_uuid=self.farm.farm_uuid, + title="Irrigation alert", + message="Soil moisture dropped", + level="warning", + metadata={"sensor": "soil-1"}, + ) + + self.assertEqual(notification.farm, self.farm) + self.assertEqual(notification.level, "warning") + self.assertEqual(notification.metadata["sensor"], "soil-1") + + def test_create_notification_for_farm_uuid_raises_for_unknown_farm(self): + with self.assertRaisesMessage(ValueError, "Farm not found."): + create_notification_for_farm_uuid( + farm_uuid="11111111-1111-1111-1111-111111111111", + title="x", + message="y", + ) + + def test_long_poll_notifications_returns_new_notifications(self): + FarmNotification.objects.create(farm=self.farm, title="A", message="B") + + notifications = long_poll_notifications(farm=self.farm, timeout_seconds=0) + + self.assertEqual(len(notifications), 1) + self.assertEqual(notifications[0].title, "A") + + def test_mark_notifications_as_read_marks_until_slice_id(self): + first = FarmNotification.objects.create(farm=self.farm, title="A", message="B") + second = FarmNotification.objects.create(farm=self.farm, title="C", message="D") + third = FarmNotification.objects.create(farm=self.farm, title="E", message="F") + + marked_count = mark_notifications_as_read(farm=self.farm, slice_id=second.id) + + self.assertEqual(marked_count, 2) + first.refresh_from_db() + second.refresh_from_db() + third.refresh_from_db() + self.assertTrue(first.is_read) + self.assertTrue(second.is_read) + self.assertFalse(third.is_read) + + + def test_get_prioritized_notifications_for_farm_returns_unread_first_and_fills_with_read(self): + unread_one = FarmNotification.objects.create(farm=self.farm, title="Unread 1", message="A") + unread_two = FarmNotification.objects.create(farm=self.farm, title="Unread 2", message="B") + read_one = FarmNotification.objects.create(farm=self.farm, title="Read 1", message="C", is_read=True) + read_two = FarmNotification.objects.create(farm=self.farm, title="Read 2", message="D", is_read=True) + + notifications = list(get_prioritized_notifications_for_farm(farm=self.farm, limit=5)) + + self.assertEqual([notification.id for notification in notifications], [unread_one.id, unread_two.id, read_one.id, read_two.id]) + + def test_long_poll_notifications_limits_to_five_unread_notifications(self): + for index in range(6): + FarmNotification.objects.create(farm=self.farm, title=f"Unread {index}", message="B") + + notifications = long_poll_notifications(farm=self.farm, timeout_seconds=0, limit=5) + + self.assertEqual(len(notifications), 5) + self.assertTrue(all(notification.is_read is False for notification in notifications)) + + +class NotificationLongPollViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notif-view-user", + password="secret123", + email="notif-view@example.com", + phone_number="09120000012", + ) + self.other_user = get_user_model().objects.create_user( + username="notif-other-user", + password="secret123", + email="notif-other@example.com", + phone_number="09120000013", + ) + self.farm_type = FarmType.objects.create(name="دامداری") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm B", + ) + + def test_long_poll_view_returns_notifications_for_owned_farm(self): + notification = FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor") + request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0") + force_authenticate(request, user=self.user) + + response = NotificationLongPollView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["title"], "Alert") + self.assertEqual(response.data["data"][0]["since_id"], notification.id) + self.assertFalse(response.data["data"][0]["is_read"]) + + def test_long_poll_view_returns_unread_first_then_read_when_unread_is_less_than_five(self): + unread_one = FarmNotification.objects.create(farm=self.farm, title="Unread 1", message="A") + unread_two = FarmNotification.objects.create(farm=self.farm, title="Unread 2", message="B") + read_one = FarmNotification.objects.create(farm=self.farm, title="Read 1", message="C", is_read=True) + read_two = FarmNotification.objects.create(farm=self.farm, title="Read 2", message="D", is_read=True) + request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0") + force_authenticate(request, user=self.user) + + response = NotificationLongPollView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [item["since_id"] for item in response.data["data"]], + [unread_one.id, unread_two.id, read_one.id, read_two.id], + ) + self.assertEqual([item["is_read"] for item in response.data["data"]], [False, False, True, True]) + + def test_long_poll_view_returns_404_for_unowned_farm(self): + request = self.factory.get(f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&timeout=0") + force_authenticate(request, user=self.other_user) + + response = NotificationLongPollView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + @patch("notifications.views.long_poll_notifications") + def test_long_poll_view_passes_since_id(self, mocked_long_poll): + mocked_long_poll.return_value = [] + request = self.factory.get( + f"/api/notifications/long-poll/?farm_uuid={self.farm.farm_uuid}&since_id=5&timeout=0" + ) + force_authenticate(request, user=self.user) + + response = NotificationLongPollView.as_view()(request) + + self.assertEqual(response.status_code, 200) + mocked_long_poll.assert_called_once() + self.assertEqual(mocked_long_poll.call_args.kwargs["since_id"], 5) + + +class NotificationMarkReadViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notif-mark-user", + password="secret123", + email="notif-mark@example.com", + phone_number="09120000015", + ) + self.other_user = get_user_model().objects.create_user( + username="notif-mark-other-user", + password="secret123", + email="notif-mark-other@example.com", + phone_number="09120000016", + ) + self.farm_type = FarmType.objects.create(name="باغ") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm D", + ) + + def test_mark_read_view_marks_notifications_up_to_slice_id(self): + first = FarmNotification.objects.create(farm=self.farm, title="Alert 1", message="Check sensor") + second = FarmNotification.objects.create(farm=self.farm, title="Alert 2", message="Check pump") + third = FarmNotification.objects.create(farm=self.farm, title="Alert 3", message="Check valve") + request = self.factory.post( + "/api/notifications/mark-as-read/", + { + "farm_uuid": str(self.farm.farm_uuid), + "slice_id": second.id, + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = NotificationMarkReadView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["marked_count"], 2) + first.refresh_from_db() + second.refresh_from_db() + third.refresh_from_db() + self.assertTrue(first.is_read) + self.assertTrue(second.is_read) + self.assertFalse(third.is_read) + + def test_mark_read_view_returns_404_for_unowned_farm(self): + notification = FarmNotification.objects.create(farm=self.farm, title="Alert", message="Check sensor") + request = self.factory.post( + "/api/notifications/mark-as-read/", + { + "farm_uuid": str(self.farm.farm_uuid), + "slice_id": notification.id, + }, + format="json", + ) + force_authenticate(request, user=self.other_user) + + response = NotificationMarkReadView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + +class NotificationListViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notif-list-user", + password="secret123", + email="notif-list@example.com", + phone_number="09120000017", + ) + self.other_user = get_user_model().objects.create_user( + username="notif-list-other-user", + password="secret123", + email="notif-list-other@example.com", + phone_number="09120000018", + ) + self.farm_type = FarmType.objects.create(name="مرغداری") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm E", + ) + + def test_list_view_returns_paginated_notifications_for_owned_farm(self): + for index in range(12): + FarmNotification.objects.create(farm=self.farm, title=f"Alert {index}", message="Check sensor") + + request = self.factory.get( + f"/api/notifications/list/?farm_uuid={self.farm.farm_uuid}&page=2&page_size=5" + ) + force_authenticate(request, user=self.user) + + response = NotificationListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 12) + self.assertEqual(len(response.data["results"]["data"]), 5) + self.assertEqual(response.data["results"]["code"], 200) + + def test_list_view_returns_404_for_unowned_farm(self): + request = self.factory.get(f"/api/notifications/list/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.other_user) + + response = NotificationListView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") + + +@override_settings(EXTERNAL_NOTIFICATION_API_KEY="12345") +class ExternalNotificationIngestViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="notif-external-user", + password="secret123", + email="notif-external@example.com", + phone_number="09120000014", + ) + self.farm_type = FarmType.objects.create(name="آبی") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Farm C", + ) + + def test_external_ingest_requires_api_key(self): + request = self.factory.post( + "/api/notifications/external/ingest/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "external", + "message": "payload", + }, + format="json", + ) + + response = ExternalNotificationIngestView.as_view()(request) + + self.assertEqual(response.status_code, 401) + + def test_external_ingest_creates_notification_with_valid_api_key(self): + request = self.factory.post( + "/api/notifications/external/ingest/", + { + "farm_uuid": str(self.farm.farm_uuid), + "title": "Pump alert", + "message": "Pump disconnected", + "level": "critical", + "metadata": {"source": "external-service"}, + }, + format="json", + HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY, + ) + + response = ExternalNotificationIngestView.as_view()(request) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["data"]["title"], "Pump alert") + self.assertTrue( + FarmNotification.objects.filter(farm=self.farm, title="Pump alert", level="critical").exists() + ) + + def test_external_ingest_returns_404_for_unknown_farm(self): + request = self.factory.post( + "/api/notifications/external/ingest/", + { + "farm_uuid": "11111111-1111-1111-1111-111111111111", + "title": "Pump alert", + "message": "Pump disconnected", + }, + format="json", + HTTP_X_API_KEY=settings.EXTERNAL_NOTIFICATION_API_KEY, + ) + + response = ExternalNotificationIngestView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["msg"], "Farm not found.") diff --git a/Modules/Backend/notifications/urls.py b/Modules/Backend/notifications/urls.py new file mode 100644 index 0000000..9de53bf --- /dev/null +++ b/Modules/Backend/notifications/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import NotificationListView, NotificationLongPollView, NotificationMarkReadView + +urlpatterns = [ + path("list/", NotificationListView.as_view(), name="notification-list"), + path("long-poll/", NotificationLongPollView.as_view(), name="notification-long-poll"), + path("mark-as-read/", NotificationMarkReadView.as_view(), name="notification-mark-as-read"), +] diff --git a/Modules/Backend/notifications/views.py b/Modules/Backend/notifications/views.py new file mode 100644 index 0000000..63d5370 --- /dev/null +++ b/Modules/Backend/notifications/views.py @@ -0,0 +1,160 @@ +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema +from rest_framework import serializers, status +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.swagger import code_response +from farm_hub.models import FarmHub + +from .serializers import FarmNotificationSerializer +from .services import long_poll_notifications, mark_notifications_as_read + + +class NotificationLongPollQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111") + since_id = serializers.IntegerField(required=False, min_value=1) + timeout = serializers.IntegerField(required=False, min_value=0, max_value=60) + + +class NotificationListQuerySerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111") + page = serializers.IntegerField(required=False, min_value=1) + page_size = serializers.IntegerField(required=False, min_value=1, max_value=100) + + +class NotificationPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 + + +class NotificationMarkReadSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111") + slice_id = serializers.IntegerField(min_value=1) + + +def get_owned_farm(*, farm_uuid, user): + return FarmHub.objects.filter(farm_uuid=farm_uuid, owner=user).first() + + +class NotificationLongPollView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Notifications"], + parameters=[NotificationLongPollQuerySerializer], + responses={ + 200: code_response("NotificationLongPollResponse", data=FarmNotificationSerializer(many=True)), + 404: code_response("NotificationLongPollNotFoundResponse"), + 503: code_response("NotificationLongPollNotificationsUnavailableResponse"), + }, + ) + def get(self, request): + serializer = NotificationLongPollQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = get_owned_farm( + farm_uuid=serializer.validated_data["farm_uuid"], + user=request.user, + ) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + try: + notifications = long_poll_notifications( + farm=farm, + since_id=serializer.validated_data.get("since_id"), + timeout_seconds=serializer.validated_data.get("timeout", 15), + ) + except ValueError as exc: + if str(exc) == "Notifications table is not migrated.": + return Response( + {"code": 503, "msg": "Notifications table is not ready. Run migrations."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + raise + data = FarmNotificationSerializer(notifications, many=True).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class NotificationListView(APIView): + permission_classes = [IsAuthenticated] + pagination_class = NotificationPagination + + @extend_schema( + tags=["Notifications"], + parameters=[NotificationListQuerySerializer], + responses={ + 200: code_response("NotificationListResponse"), + 404: code_response("NotificationListNotFoundResponse"), + }, + ) + def get(self, request): + serializer = NotificationListQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + farm = get_owned_farm( + farm_uuid=serializer.validated_data["farm_uuid"], + user=request.user, + ) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + paginator = self.pagination_class() + notifications = farm.notifications.all().order_by("-created_at", "-id") + page = paginator.paginate_queryset(notifications, request, view=self) + data = FarmNotificationSerializer(page, many=True).data + + return paginator.get_paginated_response({ + "code": 200, + "msg": "success", + "data": data, + }) + + +class NotificationMarkReadView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Notifications"], + request=NotificationMarkReadSerializer, + responses={ + 200: code_response( + "NotificationMarkReadResponse", + extra_fields={"marked_count": serializers.IntegerField()}, + ), + 404: code_response("NotificationMarkReadNotFoundResponse"), + 503: code_response("NotificationMarkReadUnavailableResponse"), + }, + ) + def post(self, request): + serializer = NotificationMarkReadSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + farm = get_owned_farm( + farm_uuid=serializer.validated_data["farm_uuid"], + user=request.user, + ) + if farm is None: + return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND) + + try: + marked_count = mark_notifications_as_read( + farm=farm, + slice_id=serializer.validated_data["slice_id"], + ) + except ValueError as exc: + if str(exc) == "Notifications table is not migrated.": + return Response( + {"code": 503, "msg": "Notifications table is not ready. Run migrations."}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + raise + + return Response( + {"code": 200, "msg": "success", "marked_count": marked_count}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/pest_detection/__init__.py b/Modules/Backend/pest_detection/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/pest_detection/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/pest_detection/apps.py b/Modules/Backend/pest_detection/apps.py new file mode 100644 index 0000000..2a25ace --- /dev/null +++ b/Modules/Backend/pest_detection/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PestDetectionConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pest_detection" + verbose_name = "Pest Detection" diff --git a/Modules/Backend/pest_detection/mock_data.py b/Modules/Backend/pest_detection/mock_data.py new file mode 100644 index 0000000..2ea4815 --- /dev/null +++ b/Modules/Backend/pest_detection/mock_data.py @@ -0,0 +1,54 @@ +""" +Static mock data for Pest Detection API. +No database, no dynamic values. Used for analyze and risk-summary endpoint responses. +""" + +ANALYZE_RESPONSE_DATA = { + "pest": "شپشک", + "confidence": 92, + "description": "حشرات کوچک مکنده شیره که باعث پیچ خوردگی برگ می‌شوند.", + "treatment": "یک بار در هفته از اسپری روغن نیم استفاده کنید.", +} + +RISK_SUMMARY_RESPONSE_DATA = { + "disease_risk": { + "id": "disease_risk", + "title": "ریسک بیماری", + "subtitle": "۷ روز اخیر", + "stats": "پایین", + "avatarColor": "success", + "avatarIcon": "tabler-bug", + "chipText": "5%", + "chipColor": "success", + "details": { + "risk_level": "low", + "risk_percentage": 5, + "detected_diseases": [], + "last_assessed_at": "2025-07-10T06:00:00Z", + "recommendation": "شرایط فعلی مناسب است. پایش هفتگی توصیه می‌شود.", + }, + }, + "pest_risk": { + "id": "pest_risk", + "title": "ریسک آفات", + "subtitle": "پیش‌بینی هوشمند", + "stats": "15%", + "avatarColor": "warning", + "avatarIcon": "tabler-bug-off", + "chipText": "تحت نظر", + "chipColor": "warning", + "details": { + "risk_level": "moderate", + "risk_percentage": 15, + "detected_pests": [ + { + "name": "شپشک", + "confidence": 0.72, + "affected_area_percent": 8, + } + ], + "last_assessed_at": "2025-07-10T06:00:00Z", + "recommendation": "بازرسی مزرعه هر ۳ روز یک بار انجام شود. در صورت افزایش، اسپری روغن نیم توصیه می‌شود.", + }, + }, +} diff --git a/Modules/Backend/pest_detection/pest_disease_urls.py b/Modules/Backend/pest_detection/pest_disease_urls.py new file mode 100644 index 0000000..95a0d91 --- /dev/null +++ b/Modules/Backend/pest_detection/pest_disease_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import AnalyzeView, RiskSummaryView, RiskView + +urlpatterns = [ + path("detect/", AnalyzeView.as_view(), name="pest-disease-detect"), + path("risk/", RiskView.as_view(), name="pest-disease-risk"), + path("risk-summary/", RiskSummaryView.as_view(), name="pest-disease-risk-summary"), +] diff --git a/Modules/Backend/pest_detection/postman/pest_detection.json b/Modules/Backend/pest_detection/postman/pest_detection.json new file mode 100644 index 0000000..a08da4e --- /dev/null +++ b/Modules/Backend/pest_detection/postman/pest_detection.json @@ -0,0 +1 @@ +{"info":{"name":"Pest Detection","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Pest Detection API. POST analyze (optional body). Returns static pest result. No database."},"item":[{"name":"Analyze image (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{}"},"url":"{{baseUrl}}/api/pest-detection/analyze/","description":"POST with optional body (e.g. image reference). Returns static pest, confidence, description, treatment. Input not processed."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"pest\": \"شپشک\",\n \"confidence\": 92,\n \"description\": \"حشرات کوچک مکنده شیره که باعث پیچ خوردگی برگ می‌شوند.\",\n \"treatment\": \"یک بار در هفته از اسپری روغن نیم استفاده کنید.\"\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]} diff --git a/Modules/Backend/pest_detection/serializers.py b/Modules/Backend/pest_detection/serializers.py new file mode 100644 index 0000000..8883229 --- /dev/null +++ b/Modules/Backend/pest_detection/serializers.py @@ -0,0 +1,82 @@ +from rest_framework import serializers + + +class PestDetectionAnalyzeRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای تحلیل آفت/بیماری.") + sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور مرتبط در صورت وجود.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="", help_text="نام گیاه یا محصول.") + query = serializers.CharField(required=False, allow_blank=True, default="", help_text="پرسش یا توضیح متنی کاربر.") + image_urls = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + image = serializers.CharField(required=False, allow_blank=True, default="") + images = serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + ) + + def validate(self, attrs): + attrs["query"] = (attrs.get("query") or "").strip() + attrs["plant_name"] = (attrs.get("plant_name") or "").strip() + return attrs + + +class PestDetectionAnalyzeResponseSerializer(serializers.Serializer): + has_issue = serializers.BooleanField(required=False) + category = serializers.CharField(required=False, allow_blank=True) + confidence = serializers.FloatField(required=False) + severity = serializers.CharField(required=False, allow_blank=True) + summary = serializers.CharField(required=False, allow_blank=True) + detected_signs = serializers.ListField(child=serializers.CharField(), required=False) + possible_causes = serializers.ListField(child=serializers.CharField(), required=False) + immediate_actions = serializers.ListField(child=serializers.CharField(), required=False) + reasoning = serializers.ListField(child=serializers.CharField(), required=False) + + +class PestDetectionRiskRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(default="11111111-1111-1111-1111-111111111111", help_text="UUID مزرعه برای تحلیل ریسک آفت/بیماری.") + plant_name = serializers.CharField(required=False, allow_blank=True, default="پیاز", help_text="نام محصول یا گیاه.") + growth_stage = serializers.CharField(required=False, allow_blank=True, default="گلدهی", help_text="مرحله رشد گیاه.") + + +class PestDetectionRiskSummaryRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای خلاصه ریسک آفت/بیماری.") + + +class RiskBreakdownSerializer(serializers.Serializer): + score = serializers.FloatField(required=False) + level = serializers.CharField(required=False, allow_blank=True) + likely_conditions = serializers.ListField(child=serializers.CharField(), required=False) + reasoning = serializers.ListField(child=serializers.CharField(), required=False) + + +class PestDetectionRiskResponseSerializer(serializers.Serializer): + summary = serializers.CharField(required=False, allow_blank=True) + forecast_window = serializers.CharField(required=False, allow_blank=True) + overall_risk = serializers.CharField(required=False, allow_blank=True) + disease_risk = RiskBreakdownSerializer(required=False) + pest_risk = RiskBreakdownSerializer(required=False) + key_drivers = serializers.ListField(child=serializers.CharField(), required=False) + recommended_actions = serializers.ListField(child=serializers.CharField(), required=False) + + +class RiskCardSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + stats = serializers.CharField(required=False, allow_blank=True) + avatarColor = serializers.CharField(required=False, allow_blank=True) + avatarIcon = serializers.CharField(required=False, allow_blank=True) + chipText = serializers.CharField(required=False, allow_blank=True) + chipColor = serializers.CharField(required=False, allow_blank=True) + details = serializers.DictField(required=False) + + +class PestDetectionRiskSummaryResponseSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, allow_null=True) + diseaseRisk = RiskCardSerializer(required=False) + pestRisk = RiskCardSerializer(required=False) + drivers = serializers.DictField(required=False) diff --git a/Modules/Backend/pest_detection/services.py b/Modules/Backend/pest_detection/services.py new file mode 100644 index 0000000..1ad82da --- /dev/null +++ b/Modules/Backend/pest_detection/services.py @@ -0,0 +1,7 @@ +from copy import deepcopy + +from .mock_data import RISK_SUMMARY_RESPONSE_DATA + + +def get_risk_summary_data(farm=None): + return deepcopy(RISK_SUMMARY_RESPONSE_DATA) diff --git a/Modules/Backend/pest_detection/tests.py b/Modules/Backend/pest_detection/tests.py new file mode 100644 index 0000000..10c8637 --- /dev/null +++ b/Modules/Backend/pest_detection/tests.py @@ -0,0 +1,405 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType, Product + +from .views import AnalyzeView, RiskSummaryView, RiskView + + +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "pest-detection-tests", + } +} + +TEST_RISK_SUMMARY_CACHE_TTL = 14400 + + +@override_settings( + CACHES=TEST_CACHES, + PEST_DISEASE_RISK_SUMMARY_CACHE_TTL=TEST_RISK_SUMMARY_CACHE_TTL, +) +class PestDetectionViewTests(TestCase): + def setUp(self): + cache.clear() + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.product = Product.objects.create(farm_type=self.farm_type, name="پیاز") + self.farm.products.add(self.product) + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("pest_detection.views.external_api_request") + def test_analyze_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "has_issue": True, + "category": "disease", + "confidence": 0.93, + "severity": "medium", + "summary": "Leaf spot symptoms detected.", + "detected_signs": ["Brown leaf spots"], + "possible_causes": ["Fungal pressure"], + "immediate_actions": ["Isolate affected plants"], + "reasoning": ["Pattern matched common fungal lesions"], + } + } + }, + ) + + request = self.factory.post( + "/api/pest-detection/analyze/", + {"farm_uuid": str(self.farm.farm_uuid), "image_urls": ["https://example.com/leaf.jpg"]}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AnalyzeView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["category"], "disease") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/detect/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "", + "query": "", + "image_urls": ["https://example.com/leaf.jpg"], + }, + ) + + def test_analyze_requires_at_least_one_image(self): + request = self.factory.post( + "/api/pest-detection/analyze/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = AnalyzeView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertIn("images", response.data["data"]) + + @patch("pest_detection.views.external_api_request") + def test_risk_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "summary": "Warm humidity raises fungal pressure.", + "forecast_window": "72h", + "overall_risk": "medium", + "disease_risk": {"score": 0.7, "level": "medium", "likely_conditions": [], "reasoning": []}, + "pest_risk": {"score": 0.4, "level": "low", "likely_conditions": [], "reasoning": []}, + "key_drivers": ["High humidity"], + "recommended_actions": ["Scout vulnerable rows"], + } + } + }, + ) + + request = self.factory.post( + "/api/pest-detection/risk/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["overall_risk"], "medium") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "wheat", + "growth_stage": "", + }, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_maps_response_shape(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {"humidity": "high"}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["diseaseRisk"]["title"], "Disease") + self.assertEqual(response.data["data"]["pestRisk"]["title"], "Pest") + self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"}) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "پیاز", + "growth_stage": "گلدهی", + }, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_post_uses_pest_disease_route(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {"humidity": "high"}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload={ + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "پیاز", + "growth_stage": "گلدهی", + }, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_uses_blank_plant_name_when_farm_has_no_products(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {}, + } + } + }, + ) + farm_without_products = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 3") + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(farm_without_products.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload={ + "farm_uuid": str(farm_without_products.farm_uuid), + "plant_name": "", + "growth_stage": "گلدهی", + }, + ) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_caches_last_four_responses(self, mock_external_api_request): + for index in range(5): + product = Product.objects.create(farm_type=self.farm_type, name=f"Product {index}") + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Farm {index + 10}") + farm.products.add(product) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": f"Disease {index}"}, + "pest_risk": {"title": f"Pest {index}"}, + "drivers": {"index": index}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + + cached_items = cache.get(RiskSummaryView.RISK_SUMMARY_CACHE_KEY) + + self.assertEqual(len(cached_items), 4) + self.assertEqual(cached_items[0]["drivers"], {"index": 4}) + self.assertEqual(cached_items[-1]["drivers"], {"index": 1}) + + @patch("pest_detection.views.external_api_request") + def test_risk_summary_returns_cached_response_for_same_farm(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {"humidity": "high"}, + } + } + }, + ) + + for _ in range(2): + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["drivers"], {"humidity": "high"}) + + cache_key = RiskSummaryView._build_risk_summary_cache_key(self.user.id, self.farm.farm_uuid) + self.assertEqual(cache.get(cache_key)["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once() + + @patch("pest_detection.views.cache.set") + @patch("pest_detection.views.external_api_request") + def test_risk_summary_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "disease_risk": {"title": "Disease"}, + "pest_risk": {"title": "Pest"}, + "drivers": {}, + } + } + }, + ) + + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + any(call.kwargs.get("timeout") == TEST_RISK_SUMMARY_CACHE_TTL for call in mock_cache_set.call_args_list) + ) + + def test_risk_summary_rejects_extra_fields(self): + request = self.factory.post( + "/api/pest-disease/risk-summary/", + { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گندم", + "growth_stage": "رشد رویشی", + }, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertIn("non_field_errors", response.data["data"]) + + def test_risk_summary_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/pest-disease/risk-summary/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + def test_risk_summary_get_is_not_allowed(self): + request = self.factory.get(f"/api/pest-disease/risk-summary/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = RiskSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 405) + + def test_pest_disease_alias_routes_exist(self): + self.assertIs(resolve("/api/pest-disease/detect/").func.view_class, AnalyzeView) + self.assertIs(resolve("/api/pest-disease/risk/").func.view_class, RiskView) + self.assertIs(resolve("/api/pest-disease/risk-summary/").func.view_class, RiskSummaryView) diff --git a/Modules/Backend/pest_detection/urls.py b/Modules/Backend/pest_detection/urls.py new file mode 100644 index 0000000..637600f --- /dev/null +++ b/Modules/Backend/pest_detection/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/Modules/Backend/pest_detection/views.py b/Modules/Backend/pest_detection/views.py new file mode 100644 index 0000000..cc30d76 --- /dev/null +++ b/Modules/Backend/pest_detection/views.py @@ -0,0 +1,293 @@ +""" +Pest detection API views. +""" + +import json + +from django.conf import settings +from django.core.cache import cache +from drf_spectacular.utils import extend_schema +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from config.swagger import status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from .serializers import ( + PestDetectionAnalyzeRequestSerializer, + PestDetectionAnalyzeResponseSerializer, + PestDetectionRiskRequestSerializer, + PestDetectionRiskResponseSerializer, + PestDetectionRiskSummaryResponseSerializer, + PestDetectionRiskSummaryRequestSerializer, +) + + +class PestDetectionFarmMixin: + RISK_SUMMARY_CACHE_KEY = "pest-disease:risk-summary:recent" + RISK_SUMMARY_CACHE_LIMIT = 4 + + @classmethod + def _store_recent_risk_summary(cls, payload): + cached_items = cache.get(cls.RISK_SUMMARY_CACHE_KEY, []) + if not isinstance(cached_items, list): + cached_items = [] + + cached_items.insert(0, payload) + cache.set(cls.RISK_SUMMARY_CACHE_KEY, cached_items[:cls.RISK_SUMMARY_CACHE_LIMIT], timeout=None) + + @staticmethod + def _build_risk_summary_cache_key(user_id, farm_uuid): + return f"pest-disease:risk-summary:{user_id}:{farm_uuid}" + + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _parse_json_array(value): + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, list) else None + + def _collect_uploaded_images(self, request): + uploaded_images = [] + single_image = request.FILES.get("image") + if single_image is not None: + uploaded_images.append(single_image) + uploaded_images.extend(request.FILES.getlist("images")) + return uploaded_images + + def _prepare_image_urls(self, request): + image_urls = request.data.get("image_urls", []) + if isinstance(image_urls, str): + parsed = self._parse_json_array(image_urls) + image_urls = parsed if parsed is not None else [image_urls] + return [str(item) for item in image_urls if str(item).strip()] + + @staticmethod + def _get_first_farm_product_name(farm): + first_product = farm.products.order_by("id").first() + if first_product is None: + return "" + return (first_product.name or "").strip() + + @staticmethod + def _attach_uploaded_files(payload, uploaded_images): + if not uploaded_images: + return payload + + files = [] + for uploaded_image in uploaded_images: + files.append( + ( + "images", + ( + uploaded_image.name, + uploaded_image, + getattr(uploaded_image, "content_type", "application/octet-stream"), + ), + ) + ) + + multipart_payload = dict(payload) + multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False) + multipart_payload["__files__"] = files + return multipart_payload + + @staticmethod + def _extract_result_payload(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data.get("result", {}) + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + +class AnalyzeView(PestDetectionFarmMixin, APIView): + @extend_schema( + tags=["Pest Detection"], + request=PestDetectionAnalyzeRequestSerializer, + responses={200: status_response("PestDetectionAnalyzeResponse", data=PestDetectionAnalyzeResponseSerializer())}, + ) + def post(self, request): + serializer = PestDetectionAnalyzeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + image_urls = self._prepare_image_urls(request) + uploaded_images = self._collect_uploaded_images(request) + if not image_urls and not uploaded_images: + return Response( + { + "code": 400, + "msg": "error", + "data": { + "images": ["At least one image must be provided via image_urls, image, or images."], + }, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": payload.get("plant_name", ""), + "query": payload.get("query", ""), + "image_urls": image_urls, + } + sensor_uuid = payload.get("sensor_uuid") + if sensor_uuid: + ai_payload["sensor_uuid"] = str(sensor_uuid) + + ai_payload = self._attach_uploaded_files(ai_payload, uploaded_images) + + adapter_response = external_api_request( + "ai", + "/api/pest-disease/detect/", + method="POST", + payload=ai_payload, + ) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result_payload(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class RiskView(PestDetectionFarmMixin, APIView): + @extend_schema( + tags=["Pest Detection"], + request=PestDetectionRiskRequestSerializer, + responses={200: status_response("PestDetectionRiskResponse", data=PestDetectionRiskResponseSerializer())}, + ) + def post(self, request): + serializer = PestDetectionRiskRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + plant_name = self._get_first_farm_product_name(farm) + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": plant_name, + "growth_stage": "گلدهی", + } + + adapter_response = external_api_request( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload=ai_payload, + ) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result_payload(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class RiskSummaryView(PestDetectionFarmMixin, APIView): + @extend_schema( + tags=["Pest Detection"], + request=PestDetectionRiskSummaryRequestSerializer, + responses={200: status_response("PestDetectionRiskSummaryResponse", data=PestDetectionRiskSummaryResponseSerializer())}, + ) + def post(self, request): + serializer = PestDetectionRiskSummaryRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data + + farm_uuid = payload.get("farm_uuid") + + farm, error_response = self._get_farm(request, farm_uuid) + if error_response is not None: + return error_response + + cache_key = self._build_risk_summary_cache_key(request.user.id, farm.farm_uuid) + cached_response = cache.get(cache_key) + if isinstance(cached_response, dict): + return Response( + {"code": 200, "msg": "success", "data": cached_response}, + status=status.HTTP_200_OK, + ) + + plant_name = self._get_first_farm_product_name(farm) + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": plant_name, + "growth_stage": "گلدهی", + } + + adapter_response = external_api_request( + "ai", + "/api/pest-disease/risk/", + method="POST", + payload=ai_payload, + ) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + result = self._extract_result_payload(adapter_response.data) + response_payload = { + "farm_uuid": str(farm.farm_uuid), + "diseaseRisk": result.get("diseaseRisk") or result.get("disease_risk") or {}, + "pestRisk": result.get("pestRisk") or result.get("pest_risk") or {}, + "drivers": result.get("drivers") if isinstance(result.get("drivers"), dict) else {}, + } + cache.set(cache_key, response_payload, timeout=settings.PEST_DISEASE_RISK_SUMMARY_CACHE_TTL) + self._store_recent_risk_summary(response_payload) + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/plants/__init__.py b/Modules/Backend/plants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/plants/apps.py b/Modules/Backend/plants/apps.py new file mode 100644 index 0000000..d9027af --- /dev/null +++ b/Modules/Backend/plants/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PlantsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "plants" + verbose_name = "Plants" diff --git a/Modules/Backend/plants/migrations/__init__.py b/Modules/Backend/plants/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/plants/models.py b/Modules/Backend/plants/models.py new file mode 100644 index 0000000..76d018f --- /dev/null +++ b/Modules/Backend/plants/models.py @@ -0,0 +1,3 @@ +from farm_hub.models import Product + +__all__ = ["Product"] diff --git a/Modules/Backend/plants/serializers.py b/Modules/Backend/plants/serializers.py new file mode 100644 index 0000000..aaf5d0e --- /dev/null +++ b/Modules/Backend/plants/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from farm_hub.models import Product + + +class PlantSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(read_only=True) + + class Meta: + model = Product + fields = [ + "id", + "name", + "description", + "metadata", + "light", + "watering", + "soil", + "temperature", + "growth_stage", + "growth_stages", + "icon", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "health_profile", + "irrigation_profile", + "growth_profile", + "created_at", + "updated_at", + ] + + +class PlantNameSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ["name", "icon", "growth_stages"] diff --git a/Modules/Backend/plants/services.py b/Modules/Backend/plants/services.py new file mode 100644 index 0000000..88bba1d --- /dev/null +++ b/Modules/Backend/plants/services.py @@ -0,0 +1,152 @@ +from django.db import transaction +from django.conf import settings + +from external_api_adapter import request as external_api_request +from external_api_adapter.exceptions import ExternalAPIRequestError +from farm_hub.models import FarmType, Product + + +DEFAULT_FARM_TYPE_NAME = "زراعی" +DEFAULT_ICON = "leaf" +DEFAULT_GROWTH_STAGES = [ + "initial", + "vegetative", + "flowering", + "fruiting", + "maturity", +] +AI_FARM_DATA_PLANT_SYNC_PATH = "/api/farm-data/plants/sync/" + + +class PlantSyncError(Exception): + pass + + +def _clean_stage_name(value): + stage = str(value or "").strip() + return stage + + +def _merge_growth_stages(product, supplied_stages=None): + stages = [] + seen = set() + has_explicit_stage_data = False + + for stage in supplied_stages or []: + normalized = _clean_stage_name(stage) + if normalized and normalized not in seen: + has_explicit_stage_data = True + seen.add(normalized) + stages.append(normalized) + + current_stage = _clean_stage_name(getattr(product, "growth_stage", "")) + if current_stage and current_stage not in seen: + has_explicit_stage_data = True + seen.add(current_stage) + stages.append(current_stage) + + if not has_explicit_stage_data: + for stage in DEFAULT_GROWTH_STAGES: + seen.add(stage) + stages.append(stage) + + thresholds = product.growth_profile.get("stage_thresholds", {}) if isinstance(product.growth_profile, dict) else {} + if isinstance(thresholds, dict): + for stage_name in thresholds.keys(): + normalized = _clean_stage_name(stage_name) + if normalized and normalized not in seen: + seen.add(normalized) + stages.append(normalized) + + return stages + + +@transaction.atomic +def ensure_plant_defaults(queryset=None): + products = list(queryset if queryset is not None else Product.objects.all()) + updated_products = [] + + for product in products: + changed = False + + if not product.icon: + product.icon = DEFAULT_ICON + changed = True + + normalized_stages = _merge_growth_stages(product, product.growth_stages) + if normalized_stages != (product.growth_stages or []): + product.growth_stages = normalized_stages + changed = True + + if not product.growth_stage and product.growth_stages: + product.growth_stage = product.growth_stages[0] + changed = True + + if changed: + updated_products.append(product) + + if updated_products: + Product.objects.bulk_update(updated_products, ["icon", "growth_stage", "growth_stages"]) + + return products + + +def serialize_products_for_ai(products=None): + products = list(products if products is not None else Product.objects.select_related("farm_type").all().order_by("name")) + ensure_plant_defaults(products) + payload = [] + for product in products: + payload.append( + { + "id": product.id, + "name": product.name, + "slug": "", + "icon": product.icon, + "description": product.description, + "metadata": product.metadata if isinstance(product.metadata, dict) else {}, + "light": product.light, + "watering": product.watering, + "soil": product.soil, + "temperature": product.temperature, + "growth_stage": product.growth_stage, + "growth_stages": product.growth_stages or [], + "planting_season": product.planting_season, + "harvest_time": product.harvest_time, + "spacing": product.spacing, + "fertilizer": product.fertilizer, + "health_profile": product.health_profile if isinstance(product.health_profile, dict) else {}, + "irrigation_profile": product.irrigation_profile if isinstance(product.irrigation_profile, dict) else {}, + "growth_profile": product.growth_profile if isinstance(product.growth_profile, dict) else {}, + "is_active": True, + "updated_at": product.updated_at.isoformat() if product.updated_at else None, + "farm_type": product.farm_type.name if product.farm_type_id else DEFAULT_FARM_TYPE_NAME, + } + ) + return payload + + +def push_plants_to_ai(products=None): + api_key = getattr(settings, "FARM_DATA_API_KEY", "") + if not api_key: + raise PlantSyncError("FARM_DATA_API_KEY is not configured.") + + payload = serialize_products_for_ai(products) + try: + adapter_response = external_api_request( + "ai", + AI_FARM_DATA_PLANT_SYNC_PATH, + method="POST", + payload=payload, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "X-API-Key": api_key, + "Authorization": f"Api-Key {api_key}", + }, + ) + except ExternalAPIRequestError as exc: + raise PlantSyncError(str(exc)) from exc + + if adapter_response.status_code >= 400: + raise PlantSyncError(f"AI service returned status {adapter_response.status_code}.") + return payload diff --git a/Modules/Backend/plants/tests.py b/Modules/Backend/plants/tests.py new file mode 100644 index 0000000..c7cc62b --- /dev/null +++ b/Modules/Backend/plants/tests.py @@ -0,0 +1,121 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIRequestFactory, force_authenticate +from unittest.mock import patch + +from farm_hub.models import FarmHub, FarmType, Product +from .services import PlantSyncError +from .views import PlantListView, PlantNameListView, SelectedPlantListView + + +class PlantApiTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="plant-user", + password="secret123", + email="plant@example.com", + phone_number="09123334455", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + + @patch("plants.views.push_plants_to_ai") + def test_list_returns_backend_catalog_with_sync_metadata(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] + Product.objects.create( + farm_type=self.farm_type, + name="Tomato", + icon="tomato", + growth_stage="vegetative", + growth_profile={"stage_thresholds": {"flowering": 300, "fruiting": 500}}, + ) + request = self.factory.get("/api/plants/") + force_authenticate(request, user=self.user) + + response = PlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"][0]["name"], "Tomato") + self.assertEqual(response.data["data"][0]["icon"], "tomato") + self.assertIn("flowering", response.data["data"][0]["growth_stages"]) + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment") + self.assertEqual(response.data["meta"]["source_type"], "db") + self.assertEqual(response.data["meta"]["sync_status"], "synced") + mock_push_plants_to_ai.assert_called_once() + + @patch("plants.views.push_plants_to_ai") + def test_names_endpoint_fills_default_icon_and_growth_stages(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] + product = Product.objects.create( + farm_type=self.farm_type, + name="Pepper", + growth_profile={"stage_thresholds": {"fruiting": 450}}, + ) + request = self.factory.get("/api/plants/names/") + force_authenticate(request, user=self.user) + + response = PlantNameListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"][0]["icon"], "leaf") + self.assertEqual( + response.data["data"][0]["growth_stages"], + ["initial", "vegetative", "flowering", "fruiting", "maturity"], + ) + product.refresh_from_db() + self.assertEqual(product.icon, "leaf") + self.assertEqual(product.growth_stages, ["initial", "vegetative", "flowering", "fruiting", "maturity"]) + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data") + self.assertEqual(response.data["meta"]["source_type"], "db") + self.assertEqual(response.data["meta"]["sync_status"], "synced") + + @patch("plants.views.push_plants_to_ai") + def test_selected_endpoint_returns_farmer_products(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.return_value = [] + tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) + pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"]) + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="farm-a") + farm.products.add(pepper) + + request = self.factory.get(f"/api/plants/selected/?farm_uuid={farm.farm_uuid}") + force_authenticate(request, user=self.user) + + response = SelectedPlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["data"]), 1) + self.assertEqual(response.data["data"][0]["name"], "Pepper") + self.assertEqual(set(response.data["data"][0].keys()), {"name", "icon", "growth_stages"}) + self.assertNotEqual(response.data["data"][0]["name"], tomato.name) + self.assertEqual(response.data["meta"]["ownership"], "backend") + self.assertEqual(response.data["meta"]["sync_status"], "synced") + + @patch("plants.views.push_plants_to_ai") + def test_list_exposes_backend_ownership_even_when_ai_sync_fails(self, mock_push_plants_to_ai): + mock_push_plants_to_ai.side_effect = PlantSyncError("sync failed") + Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) + request = self.factory.get("/api/plants/") + force_authenticate(request, user=self.user) + + response = PlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["meta"]["flow_type"], "backend_owned_data_with_ai_enrichment") + self.assertEqual(response.data["meta"]["sync_status"], "failed") + + def test_selected_endpoint_reads_seeded_backend_products_without_runtime_mock_data(self): + tomato = Product.objects.create(farm_type=self.farm_type, name="Tomato", icon="leaf", growth_stages=["vegetative"]) + pepper = Product.objects.create(farm_type=self.farm_type, name="Pepper", icon="leaf", growth_stages=["flowering"]) + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="seeded-farm") + farm.products.add(tomato, pepper) + + request = self.factory.get(f"/api/plants/selected/?farm_uuid={farm.farm_uuid}") + force_authenticate(request, user=self.user) + + with patch("plants.views.push_plants_to_ai", return_value=[]): + response = SelectedPlantListView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertCountEqual([item["name"] for item in response.data["data"]], ["Tomato", "Pepper"]) + self.assertEqual(response.data["meta"]["source_type"], "db") diff --git a/Modules/Backend/plants/urls.py b/Modules/Backend/plants/urls.py new file mode 100644 index 0000000..7b7ab27 --- /dev/null +++ b/Modules/Backend/plants/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import PlantDetailView, PlantListView, PlantNameListView, SelectedPlantListView + +urlpatterns = [ + path("names/", PlantNameListView.as_view(), name="plant-name-list"), + path("selected/", SelectedPlantListView.as_view(), name="selected-plant-list"), + path("/", PlantDetailView.as_view(), name="plant-detail"), + path("", PlantListView.as_view(), name="plant-list"), +] diff --git a/Modules/Backend/plants/views.py b/Modules/Backend/plants/views.py new file mode 100644 index 0000000..8a40f3e --- /dev/null +++ b/Modules/Backend/plants/views.py @@ -0,0 +1,151 @@ +from rest_framework import serializers, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema + +from config.integration_contract import build_integration_meta +from config.swagger import code_response, farm_uuid_query_param +from farm_hub.models import FarmHub, Product +from .serializers import PlantNameSerializer, PlantSerializer +from .services import PlantSyncError, ensure_plant_defaults, push_plants_to_ai + + +class PlantBaseView(APIView): + permission_classes = [IsAuthenticated] + + @staticmethod + def _attempt_ai_catalog_sync(): + try: + push_plants_to_ai() + except PlantSyncError: + return False, "failed" + return True, "synced" + + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.prefetch_related("products").get(farm_uuid=farm_uuid, owner=request.user) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + +class PlantListView(PlantBaseView): + @extend_schema( + tags=["Plants"], + responses={200: code_response("PlantListResponse", data=PlantSerializer(many=True))}, + ) + def get(self, request): + products = ensure_plant_defaults(Product.objects.order_by("name")) + sync_attempted = True + sync_status = "synced" + try: + push_plants_to_ai(products) + except PlantSyncError as exc: + sync_status = "failed" + if not products: + return Response({"code": 503, "msg": str(exc)}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + data = PlantSerializer(products, many=True).data + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data_with_ai_enrichment", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + notes=["Backend plant catalog is canonical; AI receives sync snapshots only."], + ), + }, + status=status.HTTP_200_OK, + ) + + +class PlantDetailView(PlantBaseView): + @extend_schema( + tags=["Plants"], + parameters=[ + OpenApiParameter(name="plant_id", type=OpenApiTypes.INT, location=OpenApiParameter.PATH), + ], + responses={200: code_response("PlantDetailResponse", data=PlantSerializer())}, + ) + def get(self, request, plant_id): + try: + product = Product.objects.get(id=plant_id) + except Product.DoesNotExist: + return Response({"code": 404, "msg": "Plant not found."}, status=status.HTTP_404_NOT_FOUND) + + ensure_plant_defaults([product]) + product.refresh_from_db() + data = PlantSerializer(product).data + return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK) + + +class PlantNameListView(PlantBaseView): + @extend_schema( + tags=["Plants"], + responses={200: code_response("PlantNameListResponse", data=PlantNameSerializer(many=True))}, + ) + def get(self, request): + sync_attempted, sync_status = self._attempt_ai_catalog_sync() + products = ensure_plant_defaults(Product.objects.order_by("name")) + data = PlantNameSerializer(products, many=True).data + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + ), + }, + status=status.HTTP_200_OK, + ) + + +class SelectedPlantListView(PlantBaseView): + @extend_schema( + tags=["Plants"], + parameters=[farm_uuid_query_param(required=True, description="UUID of the farm to read selected plants from.")], + responses={200: code_response("SelectedPlantListResponse", data=PlantNameSerializer(many=True))}, + ) + def get(self, request): + sync_attempted, sync_status = self._attempt_ai_catalog_sync() + farm = self._get_farm(request, request.query_params.get("farm_uuid")) + ensure_plant_defaults(farm.products.all()) + products = farm.products.order_by("name") + data = PlantNameSerializer(products, many=True).data + return Response( + { + "code": 200, + "msg": "success", + "data": data, + "meta": build_integration_meta( + flow_type="backend_owned_data", + source_type="db", + source_service="backend_plants", + ownership="backend", + live=False, + cached=False, + sync_attempted=sync_attempted, + sync_status=sync_status, + ), + }, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/requirements.txt b/Modules/Backend/requirements.txt new file mode 100644 index 0000000..73eeaf3 --- /dev/null +++ b/Modules/Backend/requirements.txt @@ -0,0 +1,14 @@ +Django>=5.0,<5.2 +djangorestframework>=3.14,<3.16 +djangorestframework-simplejwt>=5.3,<5.4 +django-cors-headers>=4.3,<4.5 +drf-spectacular>=0.27,<0.28 +drf-spectacular-sidecar>=2024.7.1,<2025 +celery[redis]>=5.3,<5.4 +redis>=5.0,<5.1 + +mysqlclient>=2.2,<2.3 +gunicorn>=22,<23 +python-dotenv>=1.0,<1.1 +requests>=2.31,<2.33 + diff --git a/Modules/Backend/soil/__init__.py b/Modules/Backend/soil/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Modules/Backend/soil/__init__.py @@ -0,0 +1 @@ + diff --git a/Modules/Backend/soil/apps.py b/Modules/Backend/soil/apps.py new file mode 100644 index 0000000..9149d22 --- /dev/null +++ b/Modules/Backend/soil/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SoilConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "soil" + verbose_name = "Soil" diff --git a/Modules/Backend/soil/mock_data.py b/Modules/Backend/soil/mock_data.py new file mode 100644 index 0000000..32e60c7 --- /dev/null +++ b/Modules/Backend/soil/mock_data.py @@ -0,0 +1,83 @@ +AVG_SOIL_MOISTURE = { + "id": "avg_soil_moisture", + "title": "میانگین رطوبت خاک", + "subtitle": "کل مزرعه", + "stats": "65%", + "avatarColor": "primary", + "avatarIcon": "tabler-plant-2", + "chipText": "بهینه", + "chipColor": "success", +} + + +SENSOR_RADAR_CHART = { + "labels": ["دما", "رطوبت", "pH", "هدایت الکتریکی", "نور", "باد"], + "series": [ + {"name": "امروز", "data": [75, 65, 80, 70, 85, 60]}, + {"name": "ایده آل", "data": [80, 70, 75, 75, 90, 50]}, + ], +} + + +SENSOR_COMPARISON_CHART = { + "currentValue": 48, + "vsLastWeek": "+5%", + "vsLastWeekValue": 5, + "categories": ["دوشنبه", "سه شنبه", "چهارشنبه", "پنج شنبه", "جمعه", "شنبه", "یکشنبه"], + "series": [ + {"name": "امروز", "data": [42, 45, 48, 52, 50, 48, 46]}, + {"name": "هفته قبل", "data": [38, 40, 42, 45, 43, 40, 38]}, + ], +} + + +ANOMALY_DETECTION_CARD = { + "anomalies": [ + { + "sensor": "رطوبت خاک زون 3", + "value": "38%", + "expected": "45-65%", + "deviation": "-12%", + "severity": "warning", + }, + { + "sensor": "pH بخش 2", + "value": "5.2", + "expected": "6.0-7.0", + "deviation": "-0.8", + "severity": "error", + }, + ] +} + + +SOIL_MOISTURE_HEATMAP = { + "zones": ["زون 1", "زون 2", "زون 3", "زون 4", "زون 5", "زون 6", "زون 7"], + "hours": ["6 ص", "8 ص", "10 ص", "12 ظ", "14 ع", "16 ع", "18 ع"], + "series": [ + { + "name": "زون 1", + "data": [ + {"x": "6 ص", "y": 52}, + {"x": "8 ص", "y": 48}, + {"x": "10 ص", "y": 55}, + {"x": "12 ظ", "y": 60}, + {"x": "14 ع", "y": 58}, + {"x": "16 ع", "y": 54}, + {"x": "18 ع", "y": 50}, + ], + }, + { + "name": "زون 2", + "data": [ + {"x": "6 ص", "y": 45}, + {"x": "8 ص", "y": 42}, + {"x": "10 ص", "y": 48}, + {"x": "12 ظ", "y": 52}, + {"x": "14 ع", "y": 50}, + {"x": "16 ع", "y": 47}, + {"x": "18 ع", "y": 44}, + ], + }, + ], +} diff --git a/Modules/Backend/soil/models.py b/Modules/Backend/soil/models.py new file mode 100644 index 0000000..137941f --- /dev/null +++ b/Modules/Backend/soil/models.py @@ -0,0 +1 @@ +from django.db import models diff --git a/Modules/Backend/soil/serializers.py b/Modules/Backend/soil/serializers.py new file mode 100644 index 0000000..f05edeb --- /dev/null +++ b/Modules/Backend/soil/serializers.py @@ -0,0 +1,94 @@ +from rest_framework import serializers + + +class SoilKpiSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه کارت KPI.") + title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان کارت KPI.") + subtitle = serializers.CharField(required=False, allow_blank=True, help_text="زیرعنوان کارت KPI.") + stats = serializers.CharField(required=False, allow_blank=True, help_text="مقدار اصلی KPI.") + avatarColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ آواتار کارت.") + avatarIcon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون کارت.") + chipText = serializers.CharField(required=False, allow_blank=True, help_text="متن وضعیت KPI.") + chipColor = serializers.CharField(required=False, allow_blank=True, help_text="رنگ وضعیت KPI.") + + +class SoilRadarSeriesSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + data = serializers.ListField(child=serializers.FloatField(), required=False) + + +class SoilRadarChartSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField(), required=False) + series = SoilRadarSeriesSerializer(many=True, required=False) + + +class SoilComparisonChartSerializer(serializers.Serializer): + currentValue = serializers.FloatField(required=False) + vsLastWeek = serializers.CharField(required=False, allow_blank=True) + vsLastWeekValue = serializers.FloatField(required=False) + categories = serializers.ListField(child=serializers.CharField(), required=False) + series = SoilRadarSeriesSerializer(many=True, required=False) + + +class SoilAnomalyItemSerializer(serializers.Serializer): + sensor = serializers.CharField(required=False, allow_blank=True) + value = serializers.CharField(required=False, allow_blank=True) + expected = serializers.CharField(required=False, allow_blank=True) + deviation = serializers.CharField(required=False, allow_blank=True) + severity = serializers.CharField(required=False, allow_blank=True) + + +class SoilAnomalyDetectionSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + summary = serializers.CharField(required=False, allow_blank=True, help_text="خلاصه کوتاه ناهنجاری خاک.") + explanation = serializers.CharField(required=False, allow_blank=True, help_text="توضیح کوتاه درباره ناهنجاری.") + likely_cause = serializers.CharField(required=False, allow_blank=True, help_text="علت محتمل ناهنجاری.") + recommended_action = serializers.CharField(required=False, allow_blank=True, help_text="اقدام پیشنهادی برای رفع مشکل.") + monitoring_priority = serializers.CharField(required=False, allow_blank=True, help_text="اولویت پایش؛ low/medium/high/urgent.") + confidence = serializers.FloatField(required=False, help_text="میزان اطمینان مدل به تحلیل.") + generated_at = serializers.CharField(required=False, allow_blank=True, help_text="زمان تولید تحلیل.") + anomalies = SoilAnomalyItemSerializer(many=True, required=False) + interpretation = serializers.DictField(required=False, help_text="تفسیر ساختاریافته ناهنجاری‌ها.") + knowledge_base = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="مرجع دانشی استفاده‌شده.") + raw_response = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="پاسخ خام upstream در صورت وجود.") + + +class SoilHeatmapPointSerializer(serializers.Serializer): + x = serializers.CharField(required=False, allow_blank=True) + y = serializers.FloatField(required=False) + + +class SoilHeatmapSeriesSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + data = SoilHeatmapPointSerializer(many=True, required=False) + + +class SoilGenericDictSerializer(serializers.Serializer): + class Meta: + ref_name = "SoilGenericDict" + + +class SoilMoistureHeatmapSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + location = serializers.DictField(required=False, help_text="اطلاعات مکانی مزرعه یا ناحیه تحلیل.") + current_sensor = serializers.DictField(required=False, help_text="مشخصات سنسور فعال فعلی.") + soil_profile = serializers.ListField(child=serializers.DictField(), required=False, help_text="پروفایل خاک در لایه‌های مختلف.") + timestamp = serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="زمان تولید heatmap.") + grid_resolution = serializers.DictField(required=False, help_text="رزولوشن شبکه heatmap.") + grid_cells = serializers.ListField(child=serializers.DictField(), required=False, help_text="سلول‌های شبکه heatmap.") + sensor_points = serializers.ListField(child=serializers.DictField(), required=False, help_text="نقاط سنسور مؤثر در heatmap.") + quality_legend = serializers.DictField(required=False, help_text="legend یا بازه‌بندی کیفیت رطوبت.") + depth_layers = serializers.ListField(child=serializers.DictField(), required=False, help_text="لایه‌های عمقی خاک.") + model_metadata = serializers.DictField(required=False, help_text="متادیتای مدل تولیدکننده heatmap.") + summary = serializers.DictField(required=False, help_text="خلاصه تفسیری heatmap.") + + +class SoilSummarySerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + healthScore = serializers.IntegerField(required=False, help_text="امتیاز سلامت کلی خاک.") + profileSource = serializers.CharField(required=False, allow_blank=True, help_text="منبع پروفایل مرجع یا محصول هدف.") + healthScoreDetails = serializers.DictField(required=False, help_text="جزئیات تشکیل‌دهنده health score.") + healthLanguage = serializers.DictField(required=False, help_text="توضیحات متنی قابل نمایش برای سلامت خاک.") + avgSoilMoisture = serializers.IntegerField(required=False, help_text="میانگین رطوبت خاک به‌صورت عدد گرد شده.") + avgSoilMoistureRaw = serializers.FloatField(required=False, help_text="میانگین خام رطوبت خاک.") + avgSoilMoistureStatus = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت متنی رطوبت خاک.") diff --git a/Modules/Backend/soil/services.py b/Modules/Backend/soil/services.py new file mode 100644 index 0000000..dfbc70a --- /dev/null +++ b/Modules/Backend/soil/services.py @@ -0,0 +1,84 @@ +from copy import deepcopy + +from farm_alerts.models import AnomalyDetection + +from .mock_data import ( + ANOMALY_DETECTION_CARD, + AVG_SOIL_MOISTURE, + SENSOR_COMPARISON_CHART, + SENSOR_RADAR_CHART, + SOIL_MOISTURE_HEATMAP, +) + + +def get_avg_soil_moisture_data(farm=None): + data = deepcopy(AVG_SOIL_MOISTURE) + heatmap = get_soil_moisture_heatmap_data(farm) + values = [ + point.get("y") + for series in heatmap.get("series", []) + for point in series.get("data", []) + if point.get("y") is not None + ] + + if not values: + return data + + average = round(sum(values) / len(values)) + data["stats"] = f"{average}%" + if average >= 60: + data["chipText"] = "بهینه" + data["chipColor"] = "success" + elif average >= 45: + data["chipText"] = "متوسط" + data["chipColor"] = "warning" + else: + data["chipText"] = "کم" + data["chipColor"] = "error" + data["avatarColor"] = "warning" + + return data + + +def get_sensor_radar_chart_data(farm=None): + return deepcopy(SENSOR_RADAR_CHART) + + +def get_sensor_comparison_chart_data(farm=None): + return deepcopy(SENSOR_COMPARISON_CHART) + + +def get_anomaly_detection_card_data(farm=None): + if farm is None: + return deepcopy(ANOMALY_DETECTION_CARD) + + anomalies = list(AnomalyDetection.objects.filter(farm=farm)[:10]) + if not anomalies: + return deepcopy(ANOMALY_DETECTION_CARD) + + return { + "anomalies": [ + { + "sensor": anomaly.sensor, + "value": anomaly.value, + "expected": anomaly.expected, + "deviation": anomaly.deviation, + "severity": anomaly.severity, + } + for anomaly in anomalies + ] + } + + +def get_soil_moisture_heatmap_data(farm=None): + return deepcopy(SOIL_MOISTURE_HEATMAP) + + +def get_soil_summary_data(farm=None): + return { + "avgSoilMoisture": get_avg_soil_moisture_data(farm), + "sensorRadarChart": get_sensor_radar_chart_data(farm), + "sensorComparisonChart": get_sensor_comparison_chart_data(farm), + "anomalyDetectionCard": get_anomaly_detection_card_data(farm), + "soilMoistureHeatmap": get_soil_moisture_heatmap_data(farm), + } diff --git a/Modules/Backend/soil/tests.py b/Modules/Backend/soil/tests.py new file mode 100644 index 0000000..14744be --- /dev/null +++ b/Modules/Backend/soil/tests.py @@ -0,0 +1,367 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework.test import APIRequestFactory + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType +from account.models import User + +from .views import SoilAnomalyDetectionView, SoilMoistureHeatmapView, SoilSummaryView + + +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "soil-tests", + } +} + +TEST_SOIL_SUMMARY_CACHE_TTL = 14400 +TEST_SOIL_ANOMALIES_CACHE_TTL = 14400 + + +@override_settings( + CACHES=TEST_CACHES, + SOIL_ANOMALIES_CACHE_TTL=TEST_SOIL_ANOMALIES_CACHE_TTL, +) +class SoilAnomalyDetectionViewTests(TestCase): + def setUp(self): + cache.clear() + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-user", + password="secret123", + email="soil@example.com", + phone_number="09120000100", + ) + self.farm_type = FarmType.objects.create(name="Soil Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Soil Farm", + ) + + @patch("soil.views.external_api_request") + def test_anomalies_proxy_to_soile_anomaly_detection(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "summary": "summary", + "explanation": "explanation", + "likely_cause": "cause", + "recommended_action": "action", + "monitoring_priority": "high", + "confidence": 0.91, + "generated_at": "2026-04-26T10:00:00Z", + "anomalies": [], + "interpretation": {}, + "knowledge_base": None, + "raw_response": None, + } + }, + ) + + request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["monitoring_priority"], "high") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/anomaly-detection/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("soil.views.external_api_request") + def test_anomalies_cache_last_four_responses(self, mock_external_api_request): + for index in range(5): + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Farm Cache {index}") + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(farm.farm_uuid), + "summary": f"summary {index}", + "anomalies": [], + } + }, + ) + + request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={farm.farm_uuid}") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + + cached_items = cache.get("soil:anomalies:recent") + + self.assertEqual(len(cached_items), 4) + self.assertEqual(cached_items[0]["summary"], "summary 4") + self.assertEqual(cached_items[-1]["summary"], "summary 1") + + @patch("soil.views.external_api_request") + def test_anomalies_return_cached_response_for_same_farm(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "summary": "cached summary", + "anomalies": [], + } + }, + ) + + for _ in range(2): + request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["summary"], "cached summary") + + self.assertEqual(cache.get(f"soil:anomalies:{self.farm.farm_uuid}")["summary"], "cached summary") + mock_external_api_request.assert_called_once() + + @patch("soil.views.cache.set") + @patch("soil.views.external_api_request") + def test_anomalies_use_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "summary": "summary", + "anomalies": [], + } + }, + ) + + request = self.factory.get(f"/api/soil/anomalies/?farm_uuid={self.farm.farm_uuid}") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_ANOMALIES_CACHE_TTL for call in mock_cache_set.call_args_list)) + + def test_anomalies_require_farm_uuid(self): + request = self.factory.get("/api/soil/anomalies/") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_anomalies_return_404_for_missing_farm(self): + request = self.factory.get("/api/soil/anomalies/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilAnomalyDetectionView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + +class SoilMoistureHeatmapViewTests(TestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-heatmap-user", + password="secret123", + email="soil-heatmap@example.com", + phone_number="09120000101", + ) + self.farm_type = FarmType.objects.create(name="Soil Heatmap Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Heatmap Farm", + ) + + @patch("soil.views.external_api_request") + def test_heatmap_proxies_to_soile_moisture_heatmap(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "location": {}, + "current_sensor": {}, + "soil_profile": [], + "timestamp": "2026-04-26T10:00:00Z", + "grid_resolution": {}, + "grid_cells": [], + "sensor_points": [], + "quality_legend": {}, + "depth_layers": [], + "model_metadata": {}, + "summary": {}, + } + }, + ) + + request = self.factory.get(f"/api/soil/moisture-heatmap/?farm_uuid={self.farm.farm_uuid}") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/moisture-heatmap/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + def test_heatmap_requires_farm_uuid(self): + request = self.factory.get("/api/soil/moisture-heatmap/") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_heatmap_returns_404_for_missing_farm(self): + request = self.factory.get("/api/soil/moisture-heatmap/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilMoistureHeatmapView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") + + +@override_settings( + CACHES=TEST_CACHES, + SOIL_SUMMARY_CACHE_TTL=TEST_SOIL_SUMMARY_CACHE_TTL, +) +class SoilSummaryViewTests(TestCase): + def setUp(self): + cache.clear() + self.factory = APIRequestFactory() + self.user = User.objects.create_user( + username="soil-summary-user", + password="secret123", + email="soil-summary@example.com", + phone_number="09120000102", + ) + self.farm_type = FarmType.objects.create(name="Soil Summary Farm Type") + self.farm = FarmHub.objects.create( + owner=self.user, + farm_type=self.farm_type, + name="Summary Farm", + ) + + @patch("soil.views.external_api_request") + def test_summary_proxies_to_soile_health_summary(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "healthScore": 82, + "profileSource": "Tomato", + "healthScoreDetails": {}, + "healthLanguage": {}, + "avgSoilMoisture": 46, + "avgSoilMoistureRaw": 46.0, + "avgSoilMoistureStatus": "بهینه", + } + }, + ) + + request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["healthScore"], 82) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/soile/health-summary/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("soil.views.external_api_request") + def test_summary_returns_cached_response_for_same_farm(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "healthScore": 82, + "profileSource": "Tomato", + } + }, + ) + + for _ in range(2): + request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["healthScore"], 82) + + self.assertEqual(cache.get(f"soil:summary:{self.farm.farm_uuid}")["healthScore"], 82) + mock_external_api_request.assert_called_once() + + @patch("soil.views.cache.set") + @patch("soil.views.external_api_request") + def test_summary_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(self.farm.farm_uuid), + "healthScore": 82, + } + }, + ) + + request = self.factory.get(f"/api/soil/summary/?farm_uuid={self.farm.farm_uuid}") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertTrue(any(call.kwargs.get("timeout") == TEST_SOIL_SUMMARY_CACHE_TTL for call in mock_cache_set.call_args_list)) + + @patch("soil.views.external_api_request") + def test_summary_caches_last_four_responses(self, mock_external_api_request): + for index in range(5): + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Soil Summary Cache {index}") + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "farm_uuid": str(farm.farm_uuid), + "healthScore": 80 + index, + } + }, + ) + + request = self.factory.get(f"/api/soil/summary/?farm_uuid={farm.farm_uuid}") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + + cached_items = cache.get("soil:summary:recent") + self.assertEqual(len(cached_items), 4) + self.assertEqual(cached_items[0]["healthScore"], 84) + self.assertEqual(cached_items[-1]["healthScore"], 81) + + def test_summary_requires_farm_uuid(self): + request = self.factory.get("/api/soil/summary/") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["code"], 400) + self.assertEqual(response.data["data"]["farm_uuid"][0], "This field is required.") + + def test_summary_returns_404_for_missing_farm(self): + request = self.factory.get("/api/soil/summary/?farm_uuid=11111111-1111-1111-1111-111111111111") + response = SoilSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") diff --git a/Modules/Backend/soil/urls.py b/Modules/Backend/soil/urls.py new file mode 100644 index 0000000..1d766ca --- /dev/null +++ b/Modules/Backend/soil/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from .views import ( + AvgSoilMoistureView, + SoilAnomalyDetectionView, + SoilMoistureHeatmapView, + SoilSummaryView, +) + +urlpatterns = [ + path("avg-moisture/", AvgSoilMoistureView.as_view(), name="soil-avg-moisture"), + path("anomalies/", SoilAnomalyDetectionView.as_view(), name="soil-anomalies"), + path("moisture-heatmap/", SoilMoistureHeatmapView.as_view(), name="soil-moisture-heatmap"), + path("summary/", SoilSummaryView.as_view(), name="soil-summary"), +] diff --git a/Modules/Backend/soil/views.py b/Modules/Backend/soil/views.py new file mode 100644 index 0000000..3ceb7cd --- /dev/null +++ b/Modules/Backend/soil/views.py @@ -0,0 +1,257 @@ +from django.conf import settings +from django.core.cache import cache +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from config.swagger import farm_uuid_query_param, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub + +from .serializers import ( + SoilAnomalyDetectionSerializer, + SoilKpiSerializer, + SoilMoistureHeatmapSerializer, + SoilSummarySerializer, +) +from .services import ( + get_anomaly_detection_card_data, + get_avg_soil_moisture_data, + get_soil_moisture_heatmap_data, +) + + +SOIL_ANOMALIES_CACHE_KEY = "soil:anomalies:recent" +SOIL_ANOMALIES_CACHE_LIMIT = 4 +SOIL_SUMMARY_CACHE_KEY = "soil:summary:recent" +SOIL_SUMMARY_CACHE_LIMIT = 4 + + +def _store_recent_soil_anomalies(payload): + cached_items = cache.get(SOIL_ANOMALIES_CACHE_KEY, []) + if not isinstance(cached_items, list): + cached_items = [] + + cached_items.insert(0, payload) + cache.set(SOIL_ANOMALIES_CACHE_KEY, cached_items[:SOIL_ANOMALIES_CACHE_LIMIT], timeout=None) + + +def _store_recent_soil_summary(payload): + cached_items = cache.get(SOIL_SUMMARY_CACHE_KEY, []) + if not isinstance(cached_items, list): + cached_items = [] + + cached_items.insert(0, payload) + cache.set(SOIL_SUMMARY_CACHE_KEY, cached_items[:SOIL_SUMMARY_CACHE_LIMIT], timeout=None) + + +def _build_soil_summary_cache_key(farm_uuid): + return f"soil:summary:{farm_uuid}" + + +def _build_soil_anomalies_cache_key(farm_uuid): + return f"soil:anomalies:{farm_uuid}" + + + +def _get_farm_from_request(request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return None + try: + return FarmHub.objects.get(farm_uuid=farm_uuid) + except (FarmHub.DoesNotExist, Exception): + return None + + +def _extract_adapter_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + +class AvgSoilMoistureView(APIView): + @extend_schema( + tags=["Soil"], + parameters=[ + farm_uuid_query_param(required=False, description="UUID of the farm for average soil moisture."), + ], + responses={200: status_response("AvgSoilMoistureResponse", data=SoilKpiSerializer())}, + ) + def get(self, request): + return Response( + {"status": "success", "data": get_avg_soil_moisture_data(_get_farm_from_request(request))}, + status=status.HTTP_200_OK, + ) + + +class SoilAnomalyDetectionView(APIView): + @extend_schema( + tags=["Soil"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for soil anomaly detection."), + ], + responses={200: status_response("SoilAnomalyDetectionResponse", data=SoilAnomalyDetectionSerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + cache_key = _build_soil_anomalies_cache_key(farm.farm_uuid) + cached_anomalies = cache.get(cache_key) + if isinstance(cached_anomalies, dict): + return Response( + {"code": 200, "msg": "success", "data": cached_anomalies}, + status=status.HTTP_200_OK, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/anomaly-detection/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + response_payload = _extract_adapter_result(adapter_response.data) + cache.set(cache_key, response_payload, timeout=settings.SOIL_ANOMALIES_CACHE_TTL) + _store_recent_soil_anomalies(response_payload) + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) + + +class SoilMoistureHeatmapView(APIView): + @extend_schema( + tags=["Soil"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for soil moisture heatmap."), + ], + responses={200: status_response("SoilMoistureHeatmapResponse", data=SoilMoistureHeatmapSerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/moisture-heatmap/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": "success", "data": _extract_adapter_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class SoilSummaryView(APIView): + @extend_schema( + tags=["Soil"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for soil health summary."), + ], + responses={200: status_response("SoilSummaryResponse", data=SoilSummarySerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if not farm_uuid: + return Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + farm = _get_farm_from_request(request) + if farm is None: + return Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + cache_key = _build_soil_summary_cache_key(farm.farm_uuid) + cached_summary = cache.get(cache_key) + if isinstance(cached_summary, dict): + return Response( + {"code": 200, "msg": "success", "data": cached_summary}, + status=status.HTTP_200_OK, + ) + + adapter_response = external_api_request( + "ai", + "/api/soile/health-summary/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + response_payload = _extract_adapter_result(adapter_response.data) + cache.set(cache_key, response_payload, timeout=settings.SOIL_SUMMARY_CACHE_TTL) + _store_recent_soil_summary(response_payload) + return Response( + {"code": 200, "msg": "success", "data": response_payload}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/water/__init__.py b/Modules/Backend/water/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/water/apps.py b/Modules/Backend/water/apps.py new file mode 100644 index 0000000..04770be --- /dev/null +++ b/Modules/Backend/water/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class WaterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "water" + label = "weather_forecast" + verbose_name = "water" diff --git a/Modules/Backend/water/defaults.py b/Modules/Backend/water/defaults.py new file mode 100644 index 0000000..d7b8bcc --- /dev/null +++ b/Modules/Backend/water/defaults.py @@ -0,0 +1,36 @@ +EMPTY_FARM_WEATHER_CARD = { + "condition": None, + "temperature": None, + "unit": "°C", + "humidity": None, + "windSpeed": None, + "windUnit": "km/h", + "chartData": {"labels": [], "series": [[]]}, + "status": "empty", + "source": "db", + "warnings": ["No persisted weather data is available for this farm."], +} + +EMPTY_WATER_NEED_PREDICTION = { + "totalNext7Days": 0, + "unit": "mm", + "categories": [], + "series": [{"name": "نیاز آبی", "data": []}], + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation water-balance data is available for this farm."], +} + +EMPTY_WATER_STRESS_INDEX = { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-droplet", + "chipText": "بدون داده", + "chipColor": "secondary", + "status": "empty", + "source": "db", + "warnings": ["No persisted irrigation stress data is available for this farm."], +} diff --git a/Modules/Backend/water/migrations/0001_initial.py b/Modules/Backend/water/migrations/0001_initial.py new file mode 100644 index 0000000..409ca24 --- /dev/null +++ b/Modules/Backend/water/migrations/0001_initial.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0007_farmhub_subscription_plan"), + ] + + operations = [ + migrations.CreateModel( + name="WeatherForecastLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("condition", models.CharField(blank=True, default="", max_length=128)), + ("temperature", models.FloatField(blank=True, null=True)), + ("unit", models.CharField(blank=True, default="°C", max_length=16)), + ("humidity", models.IntegerField(blank=True, null=True)), + ("wind_speed", models.FloatField(blank=True, null=True)), + ("wind_unit", models.CharField(blank=True, default="km/h", max_length=16)), + ("chart_data", models.JSONField(blank=True, default=dict)), + ("fetched_at", models.DateTimeField(auto_now_add=True)), + ( + "farm", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="weather_forecasts", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "weather_forecast_logs", + "ordering": ["-fetched_at"], + }, + ), + ] diff --git a/Modules/Backend/water/migrations/__init__.py b/Modules/Backend/water/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/water/mock_data.py b/Modules/Backend/water/mock_data.py new file mode 100644 index 0000000..389335b --- /dev/null +++ b/Modules/Backend/water/mock_data.py @@ -0,0 +1,34 @@ +""" +Static mock data for WATER API. +""" + +FARM_WEATHER_CARD = { + "condition": "صاف", + "temperature": 24, + "unit": "°C", + "humidity": 45, + "windSpeed": 12, + "windUnit": "km/h", + "chartData": { + "labels": ["۶ صبح", "۹ صبح", "۱۲ ظهر", "۳ بعدازظهر", "۶ عصر", "۹ شب", "۱۲ شب"], + "series": [[18, 22, 26, 28, 25, 20, 18]], + }, +} + +WATER_NEED_PREDICTION = { + "totalNext7Days": 3290, + "unit": "m3", + "categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"], + "series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}], +} + +WATER_STRESS_INDEX = { + "id": "water_stress_index", + "title": "شاخص تنش آبی", + "subtitle": "فعلی", + "stats": "12%", + "avatarColor": "info", + "avatarIcon": "tabler-droplet", + "chipText": "پایین", + "chipColor": "success", +} diff --git a/Modules/Backend/water/models.py b/Modules/Backend/water/models.py new file mode 100644 index 0000000..ffe2435 --- /dev/null +++ b/Modules/Backend/water/models.py @@ -0,0 +1,32 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub + + +class WeatherForecastLog(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="weather_forecasts", + null=True, + blank=True, + ) + condition = models.CharField(max_length=128, blank=True, default="") + temperature = models.FloatField(null=True, blank=True) + unit = models.CharField(max_length=16, blank=True, default="°C") + humidity = models.IntegerField(null=True, blank=True) + wind_speed = models.FloatField(null=True, blank=True) + wind_unit = models.CharField(max_length=16, blank=True, default="km/h") + chart_data = models.JSONField(default=dict, blank=True) + fetched_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "weather_forecast_logs" + ordering = ["-fetched_at"] + + def __str__(self): + farm_label = str(self.farm_id) if self.farm_id else "no-farm" + return f"{farm_label} — {self.condition} {self.temperature}{self.unit}" diff --git a/Modules/Backend/water/serializers.py b/Modules/Backend/water/serializers.py new file mode 100644 index 0000000..91835cd --- /dev/null +++ b/Modules/Backend/water/serializers.py @@ -0,0 +1,57 @@ +from rest_framework import serializers + + +class WeatherFarmCardRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField( + required=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه.", + ) + + +class WeatherChartDataSerializer(serializers.Serializer): + labels = serializers.ListField(child=serializers.CharField(), required=False) + series = serializers.ListField( + child=serializers.ListField(child=serializers.FloatField()), + required=False, + ) + + +class FarmWeatherCardSerializer(serializers.Serializer): + condition = serializers.CharField(required=False, allow_blank=True, help_text="وضعیت فعلی آب‌وهوا.") + temperature = serializers.FloatField(required=False, help_text="دمای فعلی.") + unit = serializers.CharField(required=False, allow_blank=True, help_text="واحد دما.") + humidity = serializers.IntegerField(required=False, help_text="رطوبت نسبی.") + windSpeed = serializers.FloatField(required=False, help_text="سرعت باد.") + windUnit = serializers.CharField(required=False, allow_blank=True, help_text="واحد سرعت باد.") + chartData = WeatherChartDataSerializer(required=False) + + +class WaterNeedSeriesSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + data = serializers.ListField(child=serializers.FloatField(), required=False) + + +class WaterNeedPredictionSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, help_text="UUID مزرعه.") + totalNext7Days = serializers.FloatField(required=False, help_text="جمع نیاز آبی ۷ روز آینده.") + unit = serializers.CharField(required=False, allow_blank=True, help_text="واحد نیاز آبی.") + categories = serializers.ListField(child=serializers.CharField(), required=False, help_text="برچسب روزها یا تاریخ‌ها.") + series = WaterNeedSeriesSerializer(many=True, required=False) + dailyBreakdown = serializers.ListField(child=serializers.DictField(), required=False, help_text="جزئیات روزانه پیش‌بینی.") + insight = serializers.DictField(required=False, help_text="جمع‌بندی و insight تحلیلی.") + knowledge_base = serializers.CharField(required=False, allow_blank=True, help_text="مرجع دانشی در صورت ارائه توسط upstream.") + raw_response = serializers.CharField(required=False, allow_blank=True, help_text="پاسخ خام upstream در صورت وجود.") + + +class WaterStressIndexSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField(required=False, allow_null=True, help_text="UUID مزرعه.") + waterStressIndex = serializers.IntegerField(required=False, help_text="شاخص تنش آبی.") + level = serializers.CharField(required=False, allow_blank=True, help_text="سطح تنش آبی.") + sourceMetric = serializers.DictField(required=False, help_text="متریک یا منبع محاسبه تنش آبی.") + + +class WaterSummarySerializer(serializers.Serializer): + farmWeatherCard = FarmWeatherCardSerializer(required=False) + waterNeedPrediction = WaterNeedPredictionSerializer(required=False) + waterStressIndex = WaterStressIndexSerializer(required=False) diff --git a/Modules/Backend/water/services.py b/Modules/Backend/water/services.py new file mode 100644 index 0000000..351f445 --- /dev/null +++ b/Modules/Backend/water/services.py @@ -0,0 +1,115 @@ +from copy import deepcopy + +from irrigation.models import IrrigationRecommendationRequest + +from .defaults import EMPTY_FARM_WEATHER_CARD, EMPTY_WATER_NEED_PREDICTION, EMPTY_WATER_STRESS_INDEX +from .models import WeatherForecastLog + + +def get_farm_weather_card_data(farm=None): + if farm is None: + return deepcopy(EMPTY_FARM_WEATHER_CARD) + + log = WeatherForecastLog.objects.filter(farm=farm).first() + if log is None: + return deepcopy(EMPTY_FARM_WEATHER_CARD) + + return { + "condition": log.condition or None, + "temperature": log.temperature if log.temperature is not None else None, + "unit": log.unit or EMPTY_FARM_WEATHER_CARD["unit"], + "humidity": log.humidity if log.humidity is not None else None, + "windSpeed": log.wind_speed if log.wind_speed is not None else None, + "windUnit": log.wind_unit or EMPTY_FARM_WEATHER_CARD["windUnit"], + "chartData": deepcopy(log.chart_data or EMPTY_FARM_WEATHER_CARD["chartData"]), + "status": "success", + "source": "db", + "warnings": [], + } + + +def _extract_irrigation_result(response_payload): + if not isinstance(response_payload, dict): + return {} + + data = response_payload.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + + result = response_payload.get("result") + if isinstance(result, dict): + return result + + return {} + + +def _get_latest_irrigation_result(farm): + if farm is None: + return {} + + for request in IrrigationRecommendationRequest.objects.filter(farm=farm): + result = _extract_irrigation_result(request.response_payload) + if result: + return result + + return {} + + +def get_water_need_prediction_data(farm=None): + default_data = deepcopy(EMPTY_WATER_NEED_PREDICTION) + result = _get_latest_irrigation_result(farm) + water_balance = result.get("water_balance", {}) + daily = water_balance.get("daily", []) + + if not daily: + return default_data + + categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)] + series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily] + + return { + "totalNext7Days": round(sum(series_data), 2), + "unit": "mm", + "categories": categories, + "series": [{"name": "نیاز آبی", "data": series_data}], + "status": "success", + "source": "db", + "warnings": [], + } + + +def get_water_stress_index_data(farm=None): + data = deepcopy(EMPTY_WATER_STRESS_INDEX) + result = _get_latest_irrigation_result(farm) + moisture_level = (result.get("plan") or {}).get("moistureLevel") + + if moisture_level is None: + return data + + stress_value = max(0, round(80 - float(moisture_level))) + if stress_value <= 15: + data["chipText"] = "پایین" + data["chipColor"] = "success" + data["avatarColor"] = "info" + elif stress_value <= 30: + data["chipText"] = "متوسط" + data["chipColor"] = "warning" + data["avatarColor"] = "warning" + else: + data["chipText"] = "بالا" + data["chipColor"] = "error" + data["avatarColor"] = "error" + + data["stats"] = f"{stress_value}%" + data["status"] = "success" + data["source"] = "db" + data["warnings"] = [] + return data + + +def get_water_summary_data(farm=None): + return { + "farmWeatherCard": get_farm_weather_card_data(farm), + "waterNeedPrediction": get_water_need_prediction_data(farm), + "waterStressIndex": get_water_stress_index_data(farm), + } diff --git a/Modules/Backend/water/tests.py b/Modules/Backend/water/tests.py new file mode 100644 index 0000000..51f9ca9 --- /dev/null +++ b/Modules/Backend/water/tests.py @@ -0,0 +1,200 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import Resolver404, resolve +from rest_framework.test import APIRequestFactory, force_authenticate + +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType + +from .views import WaterNeedPredictionView, WaterSummaryView, WeatherFarmCardView + + +TEST_CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "water-tests", + } +} + +TEST_WATER_NEED_PREDICTION_CACHE_TTL = 14400 + + +@override_settings( + CACHES=TEST_CACHES, + WATER_NEED_PREDICTION_CACHE_TTL=TEST_WATER_NEED_PREDICTION_CACHE_TTL, +) +class WeatherViewTests(TestCase): + def setUp(self): + cache.clear() + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + + @patch("water.views.external_api_request") + def test_farm_card_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"condition": "صاف", "temperature": 28.0}}}, + ) + + request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = WeatherFarmCardView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["condition"], "صاف") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/weather/farm-card/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("water.views.external_api_request") + def test_get_water_need_prediction_uses_same_ai_service_for_farm_uuid(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}}, + ) + + request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}") + + response = WaterNeedPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + self.assertEqual(response.data["data"]["totalNext7Days"], 24.6) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/weather/water-need-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid)}, + ) + + @patch("water.views.external_api_request") + def test_water_need_prediction_caches_last_four_ai_responses(self, mock_external_api_request): + farms = [] + for index in range(5): + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Farm Cache {index}") + farms.append(farm) + + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"totalNext7Days": float(index), "unit": "mm"}}}, + ) + + request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={farm.farm_uuid}") + response = WaterNeedPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + + cached_items = cache.get(WeatherFarmCardView.WATER_NEED_PREDICTION_CACHE_KEY) + + self.assertEqual(len(cached_items), 4) + self.assertEqual(cached_items[0]["totalNext7Days"], 4.0) + self.assertEqual(cached_items[-1]["totalNext7Days"], 1.0) + + @patch("water.views.external_api_request") + def test_water_need_prediction_returns_cached_response_for_same_farm(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}}, + ) + + for _ in range(2): + request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + response = WaterNeedPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["farm_uuid"], str(self.farm.farm_uuid)) + + cache_key = WeatherFarmCardView._build_water_need_prediction_cache_key(self.user.id, self.farm.farm_uuid) + self.assertEqual(cache.get(cache_key)["totalNext7Days"], 24.6) + mock_external_api_request.assert_called_once() + + @patch("water.views.cache.set") + @patch("water.views.external_api_request") + def test_water_need_prediction_uses_env_ttl_for_cache(self, mock_external_api_request, mock_cache_set): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"totalNext7Days": 24.6, "unit": "mm"}}}, + ) + + request = self.factory.get(f"/api/water/need-prediction/?farm_uuid={self.farm.farm_uuid}") + force_authenticate(request, user=self.user) + response = WaterNeedPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertTrue( + any(call.kwargs.get("timeout") == TEST_WATER_NEED_PREDICTION_CACHE_TTL for call in mock_cache_set.call_args_list) + ) + + def test_water_summary_caches_last_four_responses(self): + for index in range(5): + farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name=f"Summary Farm {index}") + request = self.factory.get(f"/api/water/summary/?farm_uuid={farm.farm_uuid}") + response = WaterSummaryView.as_view()(request) + + self.assertEqual(response.status_code, 200) + cached_items = cache.get(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY) + cached_items[0]["farmWeatherCard"]["condition"] = f"Condition {index}" + cache.set(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY, cached_items, timeout=None) + + cached_items = cache.get(WeatherFarmCardView.WATER_SUMMARY_CACHE_KEY) + + self.assertEqual(len(cached_items), 4) + self.assertEqual(cached_items[0]["farmWeatherCard"]["condition"], "Condition 4") + self.assertEqual(cached_items[-1]["farmWeatherCard"]["condition"], "Condition 1") + + def test_weather_view_rejects_foreign_farm_uuid(self): + request = self.factory.post("/api/weather/farm-card/", {"farm_uuid": str(self.other_farm.farm_uuid)}, format="json") + force_authenticate(request, user=self.user) + + response = WeatherFarmCardView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + + def test_weather_post_routes_exist_only_under_weather_prefix(self): + self.assertIs(resolve("/api/weather/farm-card/").func.view_class, WeatherFarmCardView) + + with self.assertRaises(Resolver404): + resolve("/api/water/farm-card/") + + with self.assertRaises(Resolver404): + resolve("/api/water/water-need-prediction/") + + def test_water_get_routes_do_not_exist_under_weather_prefix(self): + with self.assertRaises(Resolver404): + resolve("/api/weather/card/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/need-prediction/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/water-need-prediction/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/stress-index/") + + with self.assertRaises(Resolver404): + resolve("/api/weather/summary/") diff --git a/Modules/Backend/water/urls.py b/Modules/Backend/water/urls.py new file mode 100644 index 0000000..ec74656 --- /dev/null +++ b/Modules/Backend/water/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import FarmWeatherCardView, WaterNeedPredictionView, WaterStressIndexView, WaterSummaryView + +urlpatterns = [ + path("card/", FarmWeatherCardView.as_view(), name="water-card"), + path("need-prediction/", WaterNeedPredictionView.as_view(), name="water-need-prediction"), + path("stress-index/", WaterStressIndexView.as_view(), name="water-stress-index"), + path("summary/", WaterSummaryView.as_view(), name="water-summary"), +] diff --git a/Modules/Backend/water/views.py b/Modules/Backend/water/views.py new file mode 100644 index 0000000..b802729 --- /dev/null +++ b/Modules/Backend/water/views.py @@ -0,0 +1,347 @@ +""" +WATER API views. +""" + +from django.conf import settings +from django.core.cache import cache +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema + +from config.swagger import farm_uuid_query_param, sensor_uuid_query_param, status_response +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from .models import WeatherForecastLog +from .serializers import ( + FarmWeatherCardSerializer, + WaterNeedPredictionSerializer, + WaterStressIndexSerializer, + WaterSummarySerializer, + WeatherFarmCardRequestSerializer, +) +from .services import get_water_need_prediction_data, get_water_stress_index_data, get_water_summary_data + + +class FarmWeatherCardView(APIView): + """ + GET endpoint for the farm weather card dashboard data. + + Purpose: + Returns current weather conditions and an intraday temperature chart + for a given farm. Data is fetched from the AI external adapter. + If farm_uuid is provided and the farm exists, the result is persisted + in WeatherForecastLog for historical reference. + + Input parameters: + - farm_uuid (query, optional): UUID of the farm. + + Response structure: + - status: string, always "success". + - data: object matching the farmWeatherCard shape — condition, + temperature, unit, humidity, windSpeed, windUnit, chartData. + """ + + @extend_schema( + tags=["WATER"], + parameters=[ + farm_uuid_query_param(required=False, description="UUID of the farm to fetch weather data for."), + ], + responses={200: status_response("FarmWeatherCardResponse", data=FarmWeatherCardSerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + query = {"farm_uuid": str(farm_uuid)} if farm_uuid else {} + + adapter_response = external_api_request( + "ai", + "/weather-forecast/card", + method="GET", + query=query, + ) + + response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {} + card_data = response_data.get("result", response_data.get("data", response_data)) + + self._persist_log(farm_uuid, card_data) + + return Response( + {"status": "success", "data": card_data}, + status=status.HTTP_200_OK, + ) + + @staticmethod + def _persist_log(farm_uuid, card_data): + farm = None + if farm_uuid: + try: + farm = FarmHub.objects.get(farm_uuid=farm_uuid) + except (FarmHub.DoesNotExist, Exception): + pass + + WeatherForecastLog.objects.create( + farm=farm, + condition=card_data.get("condition", ""), + temperature=card_data.get("temperature"), + unit=card_data.get("unit", "°C"), + humidity=card_data.get("humidity"), + wind_speed=card_data.get("windSpeed"), + wind_unit=card_data.get("windUnit", "km/h"), + chart_data=card_data.get("chartData", {}), + ) + + +class WeatherFarmBaseView(APIView): + WATER_NEED_PREDICTION_CACHE_KEY = "water:need-prediction:recent" + WATER_NEED_PREDICTION_CACHE_LIMIT = 4 + WATER_SUMMARY_CACHE_KEY = "water:summary:recent" + WATER_SUMMARY_CACHE_LIMIT = 4 + + @classmethod + def _store_recent_entries(cls, cache_key, cache_limit, payload): + cached_items = cache.get(cache_key, []) + if not isinstance(cached_items, list): + cached_items = [] + + cached_items.insert(0, payload) + cache.set(cache_key, cached_items[:cache_limit], timeout=None) + + @classmethod + def _store_recent_water_need_prediction(cls, payload): + cls._store_recent_entries(cls.WATER_NEED_PREDICTION_CACHE_KEY, cls.WATER_NEED_PREDICTION_CACHE_LIMIT, payload) + + @classmethod + def _store_recent_water_summary(cls, payload): + cls._store_recent_entries(cls.WATER_SUMMARY_CACHE_KEY, cls.WATER_SUMMARY_CACHE_LIMIT, payload) + + @staticmethod + def _build_water_need_prediction_cache_key(user_id, farm_uuid): + return f"water:need-prediction:{user_id}:{farm_uuid}" + + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + @classmethod + def _fetch_water_need_prediction_data(cls, farm_uuid): + adapter_response = external_api_request( + "ai", + "/api/weather/water-need-prediction/", + method="POST", + payload={"farm_uuid": str(farm_uuid)}, + ) + if adapter_response.status_code >= 400: + return None, cls._error_response(adapter_response) + + prediction_data = cls._extract_result(adapter_response.data) + if isinstance(prediction_data, dict): + prediction_data.setdefault("farm_uuid", str(farm_uuid)) + return prediction_data, None + + +class WeatherFarmCardView(WeatherFarmBaseView): + @extend_schema( + tags=["WEATHER"], + request=WeatherFarmCardRequestSerializer, + responses={200: status_response("WeatherFarmCardResponse", data=FarmWeatherCardSerializer())}, + ) + def post(self, request): + farm, error_response = self._get_farm(request, request.data.get("farm_uuid")) + if error_response is not None: + return error_response + + adapter_response = external_api_request( + "ai", + "/api/weather/farm-card/", + method="POST", + payload={"farm_uuid": str(farm.farm_uuid)}, + ) + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + card_data = self._extract_result(adapter_response.data) + FarmWeatherCardView._persist_log(farm.farm_uuid, card_data) + return Response({"code": 200, "msg": "success", "data": card_data}, status=status.HTTP_200_OK) + + +class WaterNeedPredictionView(APIView): + @extend_schema( + tags=["WATER"], + parameters=[ + farm_uuid_query_param(required=False, description="UUID of the farm to fetch water need prediction for."), + ], + responses={200: status_response("WaterNeedPredictionResponse", data=WaterNeedPredictionSerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + if farm_uuid: + try: + farm = FarmHub.objects.get(farm_uuid=farm_uuid) + except (FarmHub.DoesNotExist, Exception): + farm = None + else: + cache_key = WeatherFarmBaseView._build_water_need_prediction_cache_key( + getattr(request.user, "id", "anonymous"), + farm.farm_uuid, + ) + cached_prediction = cache.get(cache_key) + if isinstance(cached_prediction, dict): + return Response( + {"status": "success", "data": cached_prediction}, + status=status.HTTP_200_OK, + ) + + prediction_data, error_response = WeatherFarmBaseView._fetch_water_need_prediction_data(farm.farm_uuid) + if error_response is not None: + return error_response + cache.set(cache_key, prediction_data, timeout=settings.WATER_NEED_PREDICTION_CACHE_TTL) + WeatherFarmBaseView._store_recent_water_need_prediction(prediction_data) + return Response( + {"status": "success", "data": prediction_data}, + status=status.HTTP_200_OK, + ) + else: + farm = None + + return Response( + {"status": "success", "data": get_water_need_prediction_data(farm)}, + status=status.HTTP_200_OK, + ) + + +class WaterStressIndexView(APIView): + @staticmethod + def _get_farm(farm_uuid): + if not farm_uuid: + raise serializers.ValidationError({"farm_uuid": ["This field is required."]}) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist as exc: + raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc + + @staticmethod + def extract_stress_payload(adapter_data, farm_uuid): + if not isinstance(adapter_data, dict): + return { + "farm_uuid": str(farm_uuid), + "waterStressIndex": 0, + "level": "", + "sourceMetric": {}, + } + + data = adapter_data.get("data") if isinstance(adapter_data.get("data"), dict) else adapter_data + result = data.get("result") if isinstance(data, dict) and isinstance(data.get("result"), dict) else data + + return { + "farm_uuid": str(farm_uuid), + "waterStressIndex": int(result.get("waterStressIndex") or 0), + "level": str(result.get("level") or ""), + "sourceMetric": result.get("sourceMetric") if isinstance(result.get("sourceMetric"), dict) else {}, + } + + @extend_schema( + tags=["WATER"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm to fetch water stress index for."), + sensor_uuid_query_param(), + ], + responses={200: status_response("WaterStressIndexResponse", data=WaterStressIndexSerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + sensor_uuid = request.query_params.get("sensor_uuid") + farm = self._get_farm(farm_uuid) + + query = {"farm_uuid": str(farm.farm_uuid)} + if sensor_uuid: + query["sensor_uuid"] = str(sensor_uuid) + + adapter_response = external_api_request( + "ai", + "/api/water/stress-index/", + method="GET", + query=query, + ) + + if adapter_response.status_code >= 400: + return Response( + { + "code": adapter_response.status_code, + "msg": "error", + "data": adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}, + }, + status=adapter_response.status_code, + ) + + stress_payload = self.extract_stress_payload(adapter_response.data, farm.farm_uuid) + + return Response( + {"code": 200, "msg": "success", "data": stress_payload}, + status=status.HTTP_200_OK, + ) + + +class WaterSummaryView(APIView): + @extend_schema( + tags=["WATER"], + parameters=[ + farm_uuid_query_param(required=False, description="UUID of the farm to fetch water summary for."), + ], + responses={200: status_response("WaterSummaryResponse", data=WaterSummarySerializer())}, + ) + def get(self, request): + farm = None + farm_uuid = request.query_params.get("farm_uuid") + if farm_uuid: + try: + farm = FarmHub.objects.get(farm_uuid=farm_uuid) + except (FarmHub.DoesNotExist, Exception): + farm = None + + summary_data = get_water_summary_data(farm) + WeatherFarmBaseView._store_recent_water_summary(summary_data) + return Response( + {"status": "success", "data": summary_data}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/Backend/water/weather_urls.py b/Modules/Backend/water/weather_urls.py new file mode 100644 index 0000000..0851848 --- /dev/null +++ b/Modules/Backend/water/weather_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import WeatherFarmCardView + +urlpatterns = [ + path("farm-card/", WeatherFarmCardView.as_view(), name="weather-farm-card"), +] diff --git a/Modules/Backend/yield_harvest/__init__.py b/Modules/Backend/yield_harvest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/yield_harvest/apps.py b/Modules/Backend/yield_harvest/apps.py new file mode 100644 index 0000000..f94d02c --- /dev/null +++ b/Modules/Backend/yield_harvest/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class YieldHarvestConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "yield_harvest" + verbose_name = "Yield, Harvest & Crop Simulation" diff --git a/Modules/Backend/yield_harvest/crop_simulation_urls.py b/Modules/Backend/yield_harvest/crop_simulation_urls.py new file mode 100644 index 0000000..099b20d --- /dev/null +++ b/Modules/Backend/yield_harvest/crop_simulation_urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + CurrentFarmChartView, + GrowthSimulationStatusView, + GrowthSimulationView, + HarvestPredictionView, + YieldHarvestSummaryView, + YieldPredictionView, +) + +urlpatterns = [ + path("current-farm-chart/", CurrentFarmChartView.as_view(), name="crop-simulation-current-farm-chart"), + path("growth/", GrowthSimulationView.as_view(), name="crop-simulation-growth"), + path("growth//status/", GrowthSimulationStatusView.as_view(), name="crop-simulation-growth-status"), + path("harvest-prediction/", HarvestPredictionView.as_view(), name="crop-simulation-harvest-prediction"), + path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="crop-simulation-yield-harvest-summary"), + path("yield-prediction/", YieldPredictionView.as_view(), name="crop-simulation-yield-prediction"), +] diff --git a/Modules/Backend/yield_harvest/defaults.py b/Modules/Backend/yield_harvest/defaults.py new file mode 100644 index 0000000..942d1c4 --- /dev/null +++ b/Modules/Backend/yield_harvest/defaults.py @@ -0,0 +1,31 @@ +EMPTY_YIELD_HARVEST_SUMMARY = { + "yield_prediction_card": { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": None, + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "بدون داده", + "chipColor": "secondary", + "status": "empty", + "source": "db", + }, + "yield_prediction_chart": { + "categories": [], + "series": [], + "summary": [], + "status": "empty", + "source": "db", + }, + "harvest_prediction_card": { + "date": None, + "dateFormatted": None, + "daysUntil": None, + "description": "داده پیش‌بینی برداشت هنوز ثبت نشده است.", + "optimalWindowStart": None, + "optimalWindowEnd": None, + "status": "empty", + "source": "db", + }, +} diff --git a/Modules/Backend/yield_harvest/migrations/0001_initial.py b/Modules/Backend/yield_harvest/migrations/0001_initial.py new file mode 100644 index 0000000..f6fa871 --- /dev/null +++ b/Modules/Backend/yield_harvest/migrations/0001_initial.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("farm_hub", "0007_farmhub_subscription_plan"), + ] + + operations = [ + migrations.CreateModel( + name="YieldHarvestPredictionLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)), + ("yield_stats", models.CharField(blank=True, default="", max_length=64)), + ("yield_chip_text", models.CharField(blank=True, default="", max_length=32)), + ("harvest_date", models.DateField(blank=True, null=True)), + ("days_until_harvest", models.IntegerField(blank=True, null=True)), + ("optimal_window_start", models.DateField(blank=True, null=True)), + ("optimal_window_end", models.DateField(blank=True, null=True)), + ("chart_data", models.JSONField(blank=True, default=dict)), + ("fetched_at", models.DateTimeField(auto_now_add=True)), + ( + "farm", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="yield_harvest_predictions", + to="farm_hub.farmhub", + ), + ), + ], + options={ + "db_table": "yield_harvest_prediction_logs", + "ordering": ["-fetched_at"], + }, + ), + ] diff --git a/Modules/Backend/yield_harvest/migrations/__init__.py b/Modules/Backend/yield_harvest/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/Backend/yield_harvest/mock_data.py b/Modules/Backend/yield_harvest/mock_data.py new file mode 100644 index 0000000..0ea7b1b --- /dev/null +++ b/Modules/Backend/yield_harvest/mock_data.py @@ -0,0 +1,209 @@ +""" +Static mock data for Yield & Harvest Prediction API. +Mirrors the yieldPredictionChart and harvestPredictionCard dashboard card shapes. +""" + +CONFIG_SLIDERS_ONLY = { + "sliders": [ + { + "key": "light", + "label": "نور", + "min": 0, + "max": 100, + "step": 5, + "unit_type": "percent", + "default_value": 75, + "icon": "☀️", + }, + { + "key": "water", + "label": "آب", + "min": 0, + "max": 100, + "step": 5, + "unit_type": "percent", + "default_value": 65, + "icon": "💧", + }, + { + "key": "soil_ph", + "label": "pH خاک", + "min": 4, + "max": 9, + "step": 0.5, + "unit_type": "number", + "unit": "", + "default_value": 6.5, + }, + { + "key": "growth_speed", + "label": "سرعت رشد", + "min": 0.5, + "max": 5, + "step": 0.5, + "unit_type": "number", + "unit": "x", + "default_value": 1.5, + }, + ], +} + +CONSTANTS = { + "max_height": 280, + "max_leaves": 14, + "max_branches": 6, + "max_yield": 500, + "yield_unit": "g", + "yield_rate_unit": "g/s", + "height_unit": "px", +} + +CHART_CONFIG = { + "title": "پیشرفت رشد", + "x_axis_label": "زمان (ثانیه)", + "series": [ + { + "key": "height", + "label": "ارتفاع (px)", + "y_axis_id": "yHeight", + "min": 0, + "max": 280, + "unit": "px", + }, + { + "key": "leaves", + "label": "تعداد برگ", + "y_axis_id": "yLeaf", + "min": 0, + "max": 14, + }, + { + "key": "yield", + "label": "محصول (g)", + "y_axis_id": "yYield", + "min": 0, + "max": 500, + "unit": "g", + }, + { + "key": "yield_rate", + "label": "نرخ محصول (g/s)", + "y_axis_id": "yYieldRate", + "min": 0, + "unit": "g/s", + }, + ], +} + +_labels = [f"{i * 0.2:.1f}s" for i in range(51)] +_height = [round(142 * (i / 50) ** 0.9) for i in range(51)] +_leaf = [min(5, int(i / 10)) for i in range(51)] +_yield = [round(12.4 * (i / 50) ** 1.2, 1) for i in range(51)] +_yield_rate = [round(0.087 * max(0, (i - 15) / 35), 3) for i in range(51)] + +START_RESPONSE_DATA = { + "constants": CONSTANTS, + "chart": CHART_CONFIG, + "plant": { + "height": 142, + "leaves_count": 5, + "branches_count": 2, + "fruits_count": 0, + "yield": 12.4, + "yield_rate": 0.087, + "tick": 520, + "is_healthy": True, + "can_continue": True, + }, + "progress": { + "growth_progress": 50, + "light_status": 75, + "water_status": 65, + "yield_progress": 2.5, + "yield_current": 12.4, + "yield_rate_current": 0.087, + }, + "chart_history": { + "labels": _labels, + "height_history": _height, + "leaf_history": _leaf, + "yield_history": _yield, + "yield_rate_history": _yield_rate, + }, +} + +STATE_RESPONSE_DATA = { + "plant": { + "height": 142, + "leaves_count": 5, + "branches_count": 2, + "fruits_count": 0, + "yield": 12.4, + "yield_rate": 0.087, + "tick": 520, + "is_healthy": True, + "can_continue": True, + }, + "progress": { + "growth_progress": 50, + "light_status": 75, + "water_status": 65, + "yield_progress": 2.5, + "yield_current": 12.4, + "yield_rate_current": 0.087, + }, + "chart": { + "labels": _labels, + "height_history": _height, + "leaf_history": _leaf, + "yield_history": _yield, + "yield_rate_history": _yield_rate, + }, +} + +YIELD_PREDICTION_CARD = { + "id": "yield_prediction", + "title": "پیش‌بینی عملکرد", + "subtitle": "این فصل", + "stats": "42 تن", + "avatarColor": "secondary", + "avatarIcon": "tabler-chart-bar", + "chipText": "+8%", + "chipColor": "success", +} + +YIELD_PREDICTION_CHART = { + "categories": [ + "ژانویه", "فوریه", "مارس", "آوریل", "می", "ژوئن", + "ژوئیه", "آگوست", "سپتامبر", "اکتبر", "نوامبر", "دسامبر", + ], + "series": [ + {"name": "امسال", "data": [35, 38, 40, 42, 45, 48, 50, 48, 46, 44, 42, 42]}, + {"name": "سال گذشته", "data": [32, 34, 36, 38, 40, 42, 44, 42, 40, 38, 36, 38]}, + ], + "summary": [ + { + "title": "عملکرد پیش‌بینی‌شده", + "subtitle": "این فصل", + "amount": "42 تن", + "avatarColor": "primary", + "avatarIcon": "tabler-chart-bar", + }, + { + "title": "تاریخ برداشت", + "subtitle": "حدود ۱۵ اکتبر", + "amount": "+8%", + "avatarColor": "success", + "avatarIcon": "tabler-calendar", + }, + ], +} + +HARVEST_PREDICTION_CARD = { + "date": "2025-10-15", + "dateFormatted": "۱۵ اکتبر ۲۰۲۵", + "daysUntil": 58, + "description": "بر اساس تجمع GDD فعلی و پیش‌بینی آب و هوا. بازه بهینه برداشت: ۱۲ تا ۱۸ اکتبر.", + "optimalWindowStart": "2025-10-12", + "optimalWindowEnd": "2025-10-18", +} diff --git a/Modules/Backend/yield_harvest/models.py b/Modules/Backend/yield_harvest/models.py new file mode 100644 index 0000000..632c331 --- /dev/null +++ b/Modules/Backend/yield_harvest/models.py @@ -0,0 +1,32 @@ +import uuid as uuid_lib + +from django.db import models + +from farm_hub.models import FarmHub + + +class YieldHarvestPredictionLog(models.Model): + uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True) + farm = models.ForeignKey( + FarmHub, + on_delete=models.CASCADE, + related_name="yield_harvest_predictions", + null=True, + blank=True, + ) + yield_stats = models.CharField(max_length=64, blank=True, default="") + yield_chip_text = models.CharField(max_length=32, blank=True, default="") + harvest_date = models.DateField(null=True, blank=True) + days_until_harvest = models.IntegerField(null=True, blank=True) + optimal_window_start = models.DateField(null=True, blank=True) + optimal_window_end = models.DateField(null=True, blank=True) + chart_data = models.JSONField(default=dict, blank=True) + fetched_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "yield_harvest_prediction_logs" + ordering = ["-fetched_at"] + + def __str__(self): + farm_label = str(self.farm_id) if self.farm_id else "no-farm" + return f"{farm_label} — {self.yield_stats} harvest:{self.harvest_date}" diff --git a/Modules/Backend/yield_harvest/serializers.py b/Modules/Backend/yield_harvest/serializers.py new file mode 100644 index 0000000..428ccd4 --- /dev/null +++ b/Modules/Backend/yield_harvest/serializers.py @@ -0,0 +1,192 @@ +from rest_framework import serializers + +def success_response(): + return {"status": "success"} + + +def success_with_data(data): + return {"status": "success", "data": data} + + +class YieldPredictionCardSerializer(serializers.Serializer): + id = serializers.CharField(required=False, allow_blank=True) + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + stats = serializers.CharField(required=False, allow_blank=True) + avatarColor = serializers.CharField(required=False, allow_blank=True) + avatarIcon = serializers.CharField(required=False, allow_blank=True) + chipText = serializers.CharField(required=False, allow_blank=True) + chipColor = serializers.CharField(required=False, allow_blank=True) + + +class ChartSeriesSerializer(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True) + data = serializers.ListField(child=serializers.FloatField(), required=False) + + +class ChartSummaryItemSerializer(serializers.Serializer): + title = serializers.CharField(required=False, allow_blank=True) + subtitle = serializers.CharField(required=False, allow_blank=True) + amount = serializers.CharField(required=False, allow_blank=True) + avatarColor = serializers.CharField(required=False, allow_blank=True) + avatarIcon = serializers.CharField(required=False, allow_blank=True) + + +class YieldPredictionChartSerializer(serializers.Serializer): + categories = serializers.ListField(child=serializers.CharField(), required=False) + series = ChartSeriesSerializer(many=True, required=False) + summary = ChartSummaryItemSerializer(many=True, required=False) + + +class HarvestPredictionCardSerializer(serializers.Serializer): + date = serializers.CharField(required=False, allow_blank=True) + dateFormatted = serializers.CharField(required=False, allow_blank=True) + daysUntil = serializers.IntegerField(required=False) + description = serializers.CharField(required=False, allow_blank=True) + optimalWindowStart = serializers.CharField(required=False, allow_blank=True) + optimalWindowEnd = serializers.CharField(required=False, allow_blank=True) + + +class YieldHarvestSummarySerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True) + season_highlights_card = serializers.DictField(required=False) + yield_prediction = serializers.DictField(required=False) + harvest_prediction_card = serializers.DictField(required=False) + harvest_readiness_zones = serializers.DictField(required=False) + yield_quality_bands = serializers.DictField(required=False) + harvest_operations_card = serializers.DictField(required=False) + yield_prediction_chart = serializers.DictField(required=False) + + +class CropSimulationRequestSerializer(serializers.Serializer): + farm_uuid = serializers.UUIDField( + required=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه برای اجرای شبیه‌سازی.", + ) + irrigation_plan_uuid = serializers.UUIDField( + required=False, + help_text="UUID برنامه آبیاری برای ارسال context به AI.", + ) + fertilization_plan_uuid = serializers.UUIDField( + required=False, + help_text="UUID برنامه کودی برای ارسال context به AI.", + ) + + +class GrowthSimulationRequestSerializer(serializers.Serializer): + plant_name = serializers.CharField( + required=False, + allow_blank=True, + default="", + help_text="نام گیاه؛ اگر farm_uuid ارسال شود از محصول مزرعه استفاده می‌شود.", + ) + dynamic_parameters = serializers.ListField( + child=serializers.CharField(), + required=True, + allow_empty=False, + help_text="لیست پارامترهای دینامیک موردنیاز مانند DVS یا LAI.", + ) + farm_uuid = serializers.UUIDField( + required=False, + allow_null=True, + initial="11111111-1111-1111-1111-111111111111", + help_text="UUID مزرعه؛ در صورت نبود باید weather ارسال شود.", + ) + weather = serializers.JSONField(required=False, help_text="آب‌وهوا به‌صورت object یا array.") + soil_parameters = serializers.DictField(required=False, help_text="پارامترهای خاک.") + site_parameters = serializers.DictField(required=False, help_text="پارامترهای سایت.") + crop_parameters = serializers.DictField(required=False, help_text="پارامترهای محصول.") + agromanagement = serializers.DictField(required=False, help_text="تنظیمات مدیریت زراعی.") + page_size = serializers.IntegerField(required=False, min_value=1, max_value=50, help_text="اندازه صفحه بین 1 تا 50.") + + def validate(self, attrs): + if not attrs.get("farm_uuid") and attrs.get("weather") in (None, "", [], {}): + raise serializers.ValidationError("At least one of 'farm_uuid' or 'weather' must be provided.") + if not attrs.get("farm_uuid") and not (attrs.get("plant_name") or "").strip(): + raise serializers.ValidationError({"plant_name": ["This field is required when farm_uuid is not provided."]}) + return attrs + + +class GrowthSimulationQueuedDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه تسک شبیه‌سازی رشد.") + status_url = serializers.CharField(required=False, allow_blank=True, help_text="آدرس بررسی وضعیت تسک.") + plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام گیاه شبیه‌سازی‌شده.") + + +class GrowthSimulationProgressSerializer(serializers.Serializer): + current = serializers.IntegerField(required=False, help_text="مرحله فعلی پیشرفت.") + total = serializers.IntegerField(required=False, help_text="تعداد کل مراحل.") + percent = serializers.FloatField(required=False, help_text="درصد پیشرفت.") + + +class GrowthSimulationPaginationSerializer(serializers.Serializer): + page = serializers.IntegerField(required=False, help_text="شماره صفحه فعلی.") + page_size = serializers.IntegerField(required=False, help_text="اندازه صفحه.") + total_items = serializers.IntegerField(required=False, help_text="تعداد کل آیتم‌ها.") + total_pages = serializers.IntegerField(required=False, help_text="تعداد کل صفحات.") + has_next = serializers.BooleanField(required=False, help_text="آیا صفحه بعدی وجود دارد.") + has_previous = serializers.BooleanField(required=False, help_text="آیا صفحه قبلی وجود دارد.") + + +class GrowthSimulationResultSerializer(serializers.Serializer): + plant_name = serializers.CharField(required=False, allow_blank=True) + dynamic_parameters = serializers.ListField(child=serializers.CharField(), required=False) + engine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenario_id = serializers.IntegerField(required=False) + simulation_warning = serializers.CharField(required=False, allow_blank=True) + summary_metrics = serializers.DictField(required=False) + stage_timeline = serializers.ListField(child=serializers.DictField(), required=False) + stages_page = serializers.ListField(child=serializers.DictField(), required=False) + pagination = GrowthSimulationPaginationSerializer(required=False) + daily_records_count = serializers.IntegerField(required=False) + default_page_size = serializers.IntegerField(required=False) + + +class GrowthSimulationStatusDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True) + status = serializers.CharField(required=False, allow_blank=True) + message = serializers.CharField(required=False, allow_blank=True) + progress = GrowthSimulationProgressSerializer(required=False) + result = GrowthSimulationResultSerializer(required=False) + error = serializers.CharField(required=False, allow_blank=True) + + +class CurrentFarmChartSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True, allow_null=True) + plant_name = serializers.CharField(required=False, allow_blank=True) + engine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + model_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenario_id = serializers.IntegerField(required=False) + simulation_warning = serializers.CharField(required=False, allow_blank=True) + categories = serializers.ListField(child=serializers.CharField(), required=False) + series = serializers.ListField(child=serializers.DictField(), required=False) + summary = serializers.ListField(child=serializers.DictField(), required=False) + current_state = serializers.DictField(required=False) + metrics = serializers.DictField(required=False) + daily_output = serializers.ListField(child=serializers.DictField(), required=False) + + +class HarvestPredictionSerializer(serializers.Serializer): + date = serializers.CharField(required=False, allow_blank=True) + dateFormatted = serializers.CharField(required=False, allow_blank=True) + daysUntil = serializers.IntegerField(required=False) + description = serializers.CharField(required=False, allow_blank=True) + optimalWindowStart = serializers.CharField(required=False, allow_blank=True) + optimalWindowEnd = serializers.CharField(required=False, allow_blank=True) + gddDetails = serializers.DictField(required=False) + + +class YieldPredictionSerializer(serializers.Serializer): + farm_uuid = serializers.CharField(required=False, allow_blank=True) + plant_name = serializers.CharField(required=False, allow_blank=True, allow_null=True) + predictedYieldTons = serializers.FloatField(required=False) + predictedYieldRaw = serializers.FloatField(required=False) + unit = serializers.CharField(required=False, allow_blank=True) + sourceUnit = serializers.CharField(required=False, allow_blank=True) + simulationEngine = serializers.CharField(required=False, allow_blank=True, allow_null=True) + simulationModel = serializers.CharField(required=False, allow_blank=True, allow_null=True) + scenarioId = serializers.IntegerField(required=False) + simulationWarning = serializers.CharField(required=False, allow_blank=True) + supportingMetrics = serializers.DictField(required=False) diff --git a/Modules/Backend/yield_harvest/services.py b/Modules/Backend/yield_harvest/services.py new file mode 100644 index 0000000..545098e --- /dev/null +++ b/Modules/Backend/yield_harvest/services.py @@ -0,0 +1,40 @@ +from copy import deepcopy + +from .defaults import EMPTY_YIELD_HARVEST_SUMMARY +from .models import YieldHarvestPredictionLog + + +def get_yield_harvest_summary_data(farm=None): + data = deepcopy(EMPTY_YIELD_HARVEST_SUMMARY) + + if farm is None: + return data + + log = YieldHarvestPredictionLog.objects.filter(farm=farm).first() + if log is None: + return data + + data["yield_prediction_card"]["status"] = "success" + data["yield_prediction_card"]["source"] = "db" + data["yield_prediction_chart"]["status"] = "success" + data["yield_prediction_chart"]["source"] = "db" + data["harvest_prediction_card"]["status"] = "success" + data["harvest_prediction_card"]["source"] = "db" + + if log.yield_stats: + data["yield_prediction_card"]["stats"] = log.yield_stats + if log.yield_chip_text: + data["yield_prediction_card"]["chipText"] = log.yield_chip_text + if log.chart_data: + data["yield_prediction_chart"] = deepcopy(log.chart_data) + + if log.harvest_date: + data["harvest_prediction_card"]["date"] = log.harvest_date.isoformat() + if log.days_until_harvest is not None: + data["harvest_prediction_card"]["daysUntil"] = log.days_until_harvest + if log.optimal_window_start: + data["harvest_prediction_card"]["optimalWindowStart"] = log.optimal_window_start.isoformat() + if log.optimal_window_end: + data["harvest_prediction_card"]["optimalWindowEnd"] = log.optimal_window_end.isoformat() + + return data diff --git a/Modules/Backend/yield_harvest/tests.py b/Modules/Backend/yield_harvest/tests.py new file mode 100644 index 0000000..2f9ee4d --- /dev/null +++ b/Modules/Backend/yield_harvest/tests.py @@ -0,0 +1,737 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient, APIRequestFactory, force_authenticate + +from config.observability import METRICS +from external_api_adapter.adapter import AdapterResponse +from farm_hub.models import FarmHub, FarmType, Product +from fertilization.models import FertilizationPlan +from irrigation.models import IrrigationPlan + +from .views import ( + CurrentFarmChartView, + GrowthSimulationStatusView, + GrowthSimulationView, + HarvestPredictionView, + YieldHarvestSummaryView, + YieldPredictionView, +) + + +class CropSimulationViewTests(TestCase): + def setUp(self): + self.api_client = APIClient() + self.factory = APIRequestFactory() + self.user = get_user_model().objects.create_user( + username="farmer", + password="secret123", + email="farmer@example.com", + phone_number="09120000000", + ) + self.other_user = get_user_model().objects.create_user( + username="other-farmer", + password="secret123", + email="other@example.com", + phone_number="09120000001", + ) + self.farm_type = FarmType.objects.create(name="زراعی") + self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm 1") + self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2") + self.product = Product.objects.create(farm_type=self.farm_type, name="گوجه‌فرنگی") + self.farm.products.add(self.product) + self.api_client.force_authenticate(user=self.user) + + def tearDown(self): + METRICS.clear() + + @patch("yield_harvest.views.external_api_request") + def test_growth_queues_simulation_task(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=202, + data={ + "data": { + "task_id": "growth-task-123", + "status_url": "/api/crop-simulation/growth/growth-task-123/status/", + "plant_name": "گوجه‌فرنگی", + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/growth/", + {"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS", "LAI"], "farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = GrowthSimulationView.as_view()(request) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["code"], 202) + self.assertEqual(response.data["data"]["task_id"], "growth-task-123") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/growth/", + method="POST", + payload={ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + ) + + @patch("yield_harvest.views.external_api_request") + def test_growth_top_level_route_queues_simulation_task(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=202, + data={ + "data": { + "task_id": "growth-task-123", + "status_url": "/api/crop-simulation/growth/growth-task-123/status/", + "plant_name": "گوجه‌فرنگی", + } + }, + ) + + response = self.api_client.post( + "/api/crop-simulation/growth/", + { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.json()["data"]["task_id"], "growth-task-123") + + @patch("yield_harvest.views.external_api_request") + def test_growth_yield_harvest_route_queues_simulation_task(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=202, + data={ + "data": { + "task_id": "growth-task-123", + "status_url": "/api/crop-simulation/growth/growth-task-123/status/", + "plant_name": "گوجه‌فرنگی", + } + }, + ) + + response = self.api_client.post( + "/api/yield-harvest/growth/", + { + "plant_name": "wheat", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + format="json", + ) + + self.assertEqual(response.status_code, 202) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/growth/", + method="POST", + payload={ + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "farm_uuid": str(self.farm.farm_uuid), + }, + ) + + def test_growth_requires_farm_uuid_or_weather(self): + request = self.factory.post( + "/api/yield-harvest/crop-simulation/growth/", + {"plant_name": "گوجه‌فرنگی", "dynamic_parameters": ["DVS"]}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = GrowthSimulationView.as_view()(request) + + self.assertEqual(response.status_code, 400) + + @patch("yield_harvest.views.external_api_request") + def test_growth_status_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "task_id": "growth-task-123", + "status": "SUCCESS", + "message": "done", + "progress": {}, + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "scenario_id": 1, + }, + "error": "", + } + }, + ) + + request = self.factory.get("/api/yield-harvest/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10") + force_authenticate(request, user=self.user) + response = GrowthSimulationStatusView.as_view()(request, task_id="growth-task-123") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["status"], "SUCCESS") + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/growth/growth-task-123/status/", + method="GET", + query={"page": "1", "page_size": "10"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_growth_status_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "task_id": "growth-task-123", + "status": "SUCCESS", + "message": "done", + "progress": {}, + "result": { + "plant_name": "گوجه‌فرنگی", + "dynamic_parameters": ["DVS", "LAI"], + "scenario_id": 1, + }, + "error": "", + } + }, + ) + + response = self.api_client.get("/api/crop-simulation/growth/growth-task-123/status/?page=1&page_size=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["status"], "SUCCESS") + + @patch("yield_harvest.views.external_api_request") + def test_growth_status_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"task_id": "growth-task-123", "status": "SUCCESS"}}, + ) + + response = self.api_client.get("/api/yield-harvest/growth/growth-task-123/status/?page=1&page_size=10") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["status"], "SUCCESS") + + def test_legacy_plant_simulator_routes_are_unavailable(self): + legacy_paths = [ + "/api/yield-harvest/plant-simulator/config/", + "/api/yield-harvest/plant-simulator/environment/", + "/api/yield-harvest/plant-simulator/reset/", + "/api/yield-harvest/plant-simulator/start/", + "/api/yield-harvest/plant-simulator/state/", + "/api/yield-harvest/plant-simulator/stop/", + ] + + for path in legacy_paths: + response = self.client.get(path) + self.assertEqual(response.status_code, 404, path) + + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "plant_name": "گوجه‌فرنگی", + "scenario_id": 1, + "categories": ["day1"], + "series": {"biomass": [1.2]}, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = CurrentFarmChartView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["code"], 200) + self.assertEqual(response.data["data"]["scenario_id"], 1) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/current-farm-chart/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}}}, + ) + + response = self.api_client.post( + "/api/crop-simulation/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid)) + + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "plant_name": "wheat"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/current-farm-chart/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "date": "2026-07-15", + "dateFormatted": "15 Jul 2026", + "daysUntil": 96, + "gddDetails": {"current": 800}, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = HarvestPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["daysUntil"], 96) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/harvest-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}}, + ) + + response = self.api_client.post( + "/api/crop-simulation/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["daysUntil"], 96) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/harvest-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_current_farm_chart_includes_selected_plans(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + plan_payload={"plan": {"durationMinutes": 20}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "series": []}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/current-farm-chart/", + {"farm_uuid": str(self.farm.farm_uuid), "irrigation_plan_uuid": str(irrigation_plan.uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id) + self.assertEqual(sent_payload["irrigation_plan"]["uuid"], str(irrigation_plan.uuid)) + + @patch("yield_harvest.views.external_api_request") + def test_harvest_prediction_includes_selected_plans(self, mock_external_api_request): + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-151515"}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"date": "2026-07-15", "daysUntil": 96}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/harvest-prediction/", + {"farm_uuid": str(self.farm.farm_uuid), "fertilization_plan_uuid": str(fertilization_plan.uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id) + self.assertEqual(sent_payload["fertilization_plan"]["uuid"], str(fertilization_plan.uuid)) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "predictedYieldTons": 8.4, + "scenarioId": 1, + } + } + }, + ) + + request = self.factory.post( + "/api/yield-harvest/crop-simulation/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = YieldPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["data"]["predictedYieldTons"], 8.4) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/yield-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}}, + ) + + response = self.api_client.post( + "/api/crop-simulation/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["predictedYieldTons"], 8.4) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/yield-prediction/", + method="POST", + payload={"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_falls_back_to_farm_type_product_when_farm_products_are_empty(self, mock_external_api_request): + farm_without_products = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Farm fallback") + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(farm_without_products.farm_uuid), "predictedYieldTons": 8.4}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + {"farm_uuid": str(farm_without_products.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/yield-prediction/", + method="POST", + payload={"farm_uuid": str(farm_without_products.farm_uuid), "plant_name": "گوجه‌فرنگی"}, + ) + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_includes_selected_irrigation_and_fertilization_plans(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + crop_id="گوجه‌فرنگی", + growth_stage="flowering", + plan_payload={"plan": {"durationMinutes": 30}}, + request_payload={"source": "manual"}, + response_payload={"ok": True}, + ) + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + crop_id="گوجه‌فرنگی", + growth_stage="flowering", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-202020"}}, + request_payload={"source": "manual"}, + response_payload={"ok": True}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "predictedYieldTons": 8.4}}}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + { + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_plan_uuid": str(irrigation_plan.uuid), + "fertilization_plan_uuid": str(fertilization_plan.uuid), + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + sent_payload = mock_external_api_request.call_args.kwargs["payload"] + self.assertEqual(sent_payload["irrigation_plan"]["id"], irrigation_plan.id) + self.assertEqual(sent_payload["irrigation_plan"]["plan_payload"]["plan"]["durationMinutes"], 30) + self.assertEqual(sent_payload["fertilization_plan"]["id"], fertilization_plan.id) + self.assertEqual( + sent_payload["fertilization_plan"]["plan_payload"]["primary_recommendation"]["fertilizer_code"], + "npk-202020", + ) + + def test_yield_prediction_rejects_foreign_plan_uuids(self): + other_irrigation_plan = IrrigationPlan.objects.create( + farm=self.other_farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="other irrigation", + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + { + "farm_uuid": str(self.farm.farm_uuid), + "irrigation_plan_uuid": str(other_irrigation_plan.uuid), + }, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["data"]["irrigation_plan_uuid"][0], "Irrigation plan not found.") + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_top_level_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "season_highlights_card": {"title": "Season highlights"}, + "yield_prediction": {"predicted_yield_tons": 5.1}, + "harvest_prediction_card": {"harvest_date": "2026-09-28", "days_until": 152}, + "harvest_readiness_zones": {"zones": []}, + "yield_quality_bands": {"primary_quality_grade": "B"}, + "harvest_operations_card": {"steps": []}, + "yield_prediction_chart": {"series": []}, + } + } + }, + ) + + response = self.api_client.get( + f"/api/crop-simulation/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&season_year=1404&crop_name=wheat&include_narrative=true" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid)) + mock_external_api_request.assert_called_once_with( + "ai", + "/api/crop-simulation/yield-harvest-summary/", + method="GET", + query={ + "farm_uuid": str(self.farm.farm_uuid), + "season_year": "1404", + "crop_name": "wheat", + "include_narrative": "true", + }, + ) + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_yield_harvest_route_proxies_to_ai_service(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}}, + ) + + response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"]["farm_uuid"], str(self.farm.farm_uuid)) + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_includes_selected_plans_in_query(self, mock_external_api_request): + irrigation_plan = IrrigationPlan.objects.create( + farm=self.farm, + source=IrrigationPlan.SOURCE_FREE_TEXT, + title="برنامه آبیاری", + plan_payload={"plan": {"durationMinutes": 18}}, + ) + fertilization_plan = FertilizationPlan.objects.create( + farm=self.farm, + source=FertilizationPlan.SOURCE_FREE_TEXT, + title="برنامه کودی", + plan_payload={"primary_recommendation": {"fertilizer_code": "npk-111111"}}, + ) + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={"data": {"result": {"farm_uuid": str(self.farm.farm_uuid), "yield_prediction_chart": {"series": []}}}}, + ) + + response = self.api_client.get( + f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}&irrigation_plan_uuid={irrigation_plan.uuid}&fertilization_plan_uuid={fertilization_plan.uuid}" + ) + + self.assertEqual(response.status_code, 200) + sent_query = mock_external_api_request.call_args.kwargs["query"] + self.assertEqual(sent_query["irrigation_plan"]["id"], irrigation_plan.id) + self.assertEqual(sent_query["fertilization_plan"]["id"], fertilization_plan.id) + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_records_empty_result_metric(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {}}}) + + response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}") + + self.assertEqual(response.status_code, 200) + self.assertEqual(METRICS["yield_harvest.ai.empty_result|operation=yield_harvest_summary"], 1) + + @patch("yield_harvest.views.external_api_request") + def test_yield_harvest_summary_persists_seeded_log_from_realistic_ai_contract(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=200, + data={ + "data": { + "result": { + "farm_uuid": str(self.farm.farm_uuid), + "yield_prediction": {"predicted_yield_tons": 5.1, "unit": "tons"}, + "harvest_prediction_card": { + "harvest_date": "2026-09-28", + "days_until": 152, + "optimalWindowStart": "2026-09-25", + "optimalWindowEnd": "2026-10-01", + }, + "yield_prediction_chart": {"series": [{"name": "yield", "data": []}]}, + } + } + }, + ) + + response = self.api_client.get(f"/api/yield-harvest/yield-harvest-summary/?farm_uuid={self.farm.farm_uuid}") + + self.assertEqual(response.status_code, 200) + self.assertTrue(self.farm.yield_harvest_prediction_logs.exists()) + log = self.farm.yield_harvest_prediction_logs.latest("id") + self.assertEqual(log.yield_stats, "5.1") + self.assertEqual(str(log.harvest_date), "2026-09-28") + + @patch("yield_harvest.views.external_api_request") + def test_yield_prediction_provider_unavailable_returns_explicit_failure(self, mock_external_api_request): + mock_external_api_request.return_value = AdapterResponse( + status_code=503, + data={"message": "provider unavailable"}, + ) + + response = self.api_client.post( + "/api/yield-harvest/yield-prediction/", + {"farm_uuid": str(self.farm.farm_uuid)}, + format="json", + ) + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["data"]["message"], "provider unavailable") + + def test_crop_simulation_rejects_foreign_farm_uuid(self): + request = self.factory.post( + "/api/yield-harvest/crop-simulation/yield-prediction/", + {"farm_uuid": str(self.other_farm.farm_uuid)}, + format="json", + ) + force_authenticate(request, user=self.user) + + response = YieldPredictionView.as_view()(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.data["code"], 404) + self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.") diff --git a/Modules/Backend/yield_harvest/urls.py b/Modules/Backend/yield_harvest/urls.py new file mode 100644 index 0000000..e613f27 --- /dev/null +++ b/Modules/Backend/yield_harvest/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import ( + CurrentFarmChartView, + GrowthSimulationStatusView, + GrowthSimulationView, + HarvestPredictionView, + YieldHarvestSummaryView, + YieldPredictionView, +) + +urlpatterns = [ + path("summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"), + path("current-farm-chart/", CurrentFarmChartView.as_view(), name="yield-harvest-current-farm-chart"), + path("growth/", GrowthSimulationView.as_view(), name="yield-harvest-growth"), + path("growth//status/", GrowthSimulationStatusView.as_view(), name="yield-harvest-growth-status"), + path("harvest-prediction/", HarvestPredictionView.as_view(), name="yield-harvest-harvest-prediction"), + path("yield-prediction/", YieldPredictionView.as_view(), name="yield-harvest-yield-prediction"), + path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-crop-simulation-summary"), +] diff --git a/Modules/Backend/yield_harvest/views.py b/Modules/Backend/yield_harvest/views.py new file mode 100644 index 0000000..bed5c9c --- /dev/null +++ b/Modules/Backend/yield_harvest/views.py @@ -0,0 +1,543 @@ +"""Yield & Harvest Prediction and Crop Simulation API views.""" + +import logging +import time + +from rest_framework import serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema + +from config.observability import classify_exception, log_event, observe_operation, record_metric +from config.swagger import code_response, farm_uuid_query_param +from external_api_adapter import request as external_api_request +from farm_hub.models import FarmHub +from fertilization.models import FertilizationPlan +from irrigation.models import IrrigationPlan +from .models import YieldHarvestPredictionLog +from .serializers import ( + CropSimulationRequestSerializer, + CurrentFarmChartSerializer, + GrowthSimulationQueuedDataSerializer, + GrowthSimulationRequestSerializer, + GrowthSimulationStatusDataSerializer, + HarvestPredictionSerializer, + YieldHarvestSummarySerializer, + YieldPredictionSerializer, +) + +logger = logging.getLogger(__name__) + + +class YieldHarvestSummaryView(APIView): + """ + GET endpoint for combined yield prediction and harvest prediction data. + + Purpose: + Returns three dashboard card payloads in one response: + - yield_prediction_card (kpi card shape) + - yield_prediction_chart (monthly chart + summary) + - harvest_prediction_card (harvest date + window) + Data is fetched from the AI external adapter. If farm_uuid is provided + and the farm exists, the result is persisted in YieldHarvestPredictionLog. + + Input parameters: + - farm_uuid (query, optional): UUID of the farm. + + Response structure: + - status: string, always "success". + - data: object with keys yield_prediction_card, + yield_prediction_chart, harvest_prediction_card. + """ + + @extend_schema( + tags=["Yield & Harvest Prediction"], + parameters=[ + farm_uuid_query_param(required=True, description="UUID of the farm for yield and harvest prediction."), + OpenApiParameter( + name="season_year", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="سال زراعی.", + ), + OpenApiParameter( + name="crop_name", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description="نام محصول.", + ), + OpenApiParameter( + name="include_narrative", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + required=False, + description="در صورت true بودن متن های narrative نیز اضافه می شوند.", + ), + ], + responses={200: code_response("YieldHarvestSummaryResponse", data=YieldHarvestSummarySerializer())}, + ) + def get(self, request): + farm_uuid = request.query_params.get("farm_uuid") + farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid) + if error_response is not None: + return error_response + + irrigation_plan_uuid, irrigation_plan_error = CropSimulationBaseView._parse_optional_plan_uuid( + request.query_params.get("irrigation_plan_uuid"), + "irrigation_plan_uuid", + ) + if irrigation_plan_error is not None: + return irrigation_plan_error + + fertilization_plan_uuid, fertilization_plan_error = CropSimulationBaseView._parse_optional_plan_uuid( + request.query_params.get("fertilization_plan_uuid"), + "fertilization_plan_uuid", + ) + if fertilization_plan_error is not None: + return fertilization_plan_error + + query = {"farm_uuid": str(farm.farm_uuid)} + if request.query_params.get("season_year"): + query["season_year"] = request.query_params.get("season_year") + if request.query_params.get("crop_name"): + query["crop_name"] = request.query_params.get("crop_name") + if request.query_params.get("include_narrative") is not None: + query["include_narrative"] = request.query_params.get("include_narrative") + + ai_payload, plan_error = CropSimulationBaseView()._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_uuid=irrigation_plan_uuid, + fertilization_plan_uuid=fertilization_plan_uuid, + ) + if plan_error is not None: + return plan_error + query.update(ai_payload) + + with observe_operation(source="backend.yield_harvest", provider="ai", operation="yield_harvest_summary"): + started_at = time.monotonic() + adapter_response = external_api_request( + "ai", + "/api/crop-simulation/yield-harvest-summary/", + method="GET", + query=query, + ) + if adapter_response.status_code >= 400: + record_metric("yield_harvest.ai.failure", status_code=adapter_response.status_code, operation="yield_harvest_summary") + return CropSimulationBaseView._error_response(adapter_response) + + summary = CropSimulationBaseView._extract_result(adapter_response.data) + if not summary: + record_metric("yield_harvest.ai.empty_result", operation="yield_harvest_summary") + log_event( + level=logging.WARNING, + message="yield harvest summary returned empty result", + source="backend.yield_harvest", + provider="ai", + operation="yield_harvest_summary", + result_status="empty", + duration_ms=(time.monotonic() - started_at) * 1000, + farm_uuid=str(farm.farm_uuid), + ) + + self._persist_log(farm.farm_uuid, summary) + + return Response( + {"code": 200, "msg": "success", "data": summary}, + status=status.HTTP_200_OK, + ) + + @staticmethod + def _persist_log(farm_uuid, summary): + farm = None + if farm_uuid: + try: + farm = FarmHub.objects.get(farm_uuid=farm_uuid) + except FarmHub.DoesNotExist: + logger.warning("yield_harvest log persistence skipped because farm was not found farm_uuid=%s", farm_uuid) + except Exception as exc: + failure = classify_exception(exc) + log_event( + level=logging.ERROR, + message="yield_harvest log persistence failed", + source="backend.yield_harvest", + provider="db", + operation="persist_log", + result_status="error", + error_code=failure.error_code, + farm_uuid=str(farm_uuid), + ) + return + + yield_card = summary.get("yield_prediction") or summary.get("yield_prediction_card") or {} + harvest_card = summary.get("harvest_prediction_card", {}) + yield_chart = summary.get("yield_prediction_chart", {}) + if not isinstance(yield_card, dict): + yield_card = {} + if not isinstance(harvest_card, dict): + harvest_card = {} + if not isinstance(yield_chart, dict): + yield_chart = {} + + YieldHarvestPredictionLog.objects.create( + farm=farm, + yield_stats=str(yield_card.get("predicted_yield_tons") or yield_card.get("stats") or ""), + yield_chip_text=str(yield_card.get("unit") or yield_card.get("chipText") or ""), + harvest_date=harvest_card.get("harvest_date") or harvest_card.get("date") or None, + days_until_harvest=harvest_card.get("days_until") or harvest_card.get("daysUntil"), + optimal_window_start=harvest_card.get("optimal_window_start") or harvest_card.get("optimalWindowStart") or None, + optimal_window_end=harvest_card.get("optimal_window_end") or harvest_card.get("optimalWindowEnd") or None, + chart_data=yield_chart, + ) + + +class CropSimulationBaseView(APIView): + @staticmethod + def _get_farm(request, farm_uuid): + if not farm_uuid: + return None, Response( + {"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None + except FarmHub.DoesNotExist: + return None, Response( + {"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + + @staticmethod + def _extract_result(adapter_data): + if not isinstance(adapter_data, dict): + record_metric("yield_harvest.ai.invalid_payload", operation="extract_result") + return {} + + data = adapter_data.get("data") + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + if isinstance(data, dict): + return data + + result = adapter_data.get("result") + if isinstance(result, dict): + return result + + return adapter_data + + @staticmethod + def _error_response(adapter_response): + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + log_event( + level=logging.ERROR, + message="yield_harvest upstream request failed", + source="backend.yield_harvest", + provider="ai", + operation="external_api", + result_status="error", + error_code="provider_error", + status_code=adapter_response.status_code, + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + @staticmethod + def _get_first_farm_product_name(farm): + first_product = farm.products.order_by("id").first() + if first_product is not None: + return (first_product.name or "").strip() + + fallback_product = farm.farm_type.products.order_by("id").first() + if fallback_product is not None: + return (fallback_product.name or "").strip() + + return "" + + @staticmethod + def _get_irrigation_plan_or_error(farm, plan_uuid): + if not plan_uuid: + return None, None + + plan = IrrigationPlan.objects.filter( + uuid=plan_uuid, + farm=farm, + is_deleted=False, + ).first() + if plan is None: + return None, Response( + {"code": 404, "msg": "error", "data": {"irrigation_plan_uuid": ["Irrigation plan not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + return plan, None + + @staticmethod + def _get_fertilization_plan_or_error(farm, plan_uuid): + if not plan_uuid: + return None, None + + plan = FertilizationPlan.objects.filter( + uuid=plan_uuid, + farm=farm, + is_deleted=False, + ).first() + if plan is None: + return None, Response( + {"code": 404, "msg": "error", "data": {"fertilization_plan_uuid": ["Fertilization plan not found."]}}, + status=status.HTTP_404_NOT_FOUND, + ) + return plan, None + + @staticmethod + def _build_plan_payload(plan): + if plan is None: + return None + + return { + "id": plan.id, + "uuid": str(plan.uuid), + "source": plan.source, + "title": plan.title, + "crop_id": plan.crop_id, + "growth_stage": plan.growth_stage, + "is_active": plan.is_active, + "plan_payload": plan.plan_payload if isinstance(plan.plan_payload, dict) else {}, + "request_payload": plan.request_payload if isinstance(plan.request_payload, dict) else {}, + "response_payload": plan.response_payload if isinstance(plan.response_payload, dict) else {}, + } + + def _build_ai_payload_with_selected_plans(self, farm, irrigation_plan_uuid=None, fertilization_plan_uuid=None): + irrigation_plan, irrigation_error = self._get_irrigation_plan_or_error(farm, irrigation_plan_uuid) + if irrigation_error is not None: + return None, irrigation_error + + fertilization_plan, fertilization_error = self._get_fertilization_plan_or_error( + farm, fertilization_plan_uuid + ) + if fertilization_error is not None: + return None, fertilization_error + + ai_payload = { + "farm_uuid": str(farm.farm_uuid), + "plant_name": self._get_first_farm_product_name(farm), + } + if irrigation_plan is not None: + ai_payload["irrigation_plan"] = self._build_plan_payload(irrigation_plan) + if fertilization_plan is not None: + ai_payload["fertilization_plan"] = self._build_plan_payload(fertilization_plan) + + return ai_payload, None + + @staticmethod + def _parse_optional_plan_uuid(raw_value, field_name): + if raw_value in (None, ""): + return None, None + try: + parsed_value = str(serializers.UUIDField().to_internal_value(raw_value)) + except serializers.ValidationError: + return None, Response( + {"code": 400, "msg": "error", "data": {field_name: ["Must be a valid UUID."]}}, + status=status.HTTP_400_BAD_REQUEST, + ) + return parsed_value, None + + +class CurrentFarmChartView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/current-farm-chart/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: code_response("CurrentFarmChartResponse", data=CurrentFarmChartSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_uuid=payload.get("irrigation_plan_uuid"), + fertilization_plan_uuid=payload.get("fertilization_plan_uuid"), + ) + if plan_error is not None: + return plan_error + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class HarvestPredictionView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/harvest-prediction/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: code_response("CropSimulationHarvestPredictionResponse", data=HarvestPredictionSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_uuid=payload.get("irrigation_plan_uuid"), + fertilization_plan_uuid=payload.get("fertilization_plan_uuid"), + ) + if plan_error is not None: + return plan_error + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class YieldPredictionView(CropSimulationBaseView): + ai_path = "/api/crop-simulation/yield-prediction/" + + @extend_schema( + tags=["Crop Simulation"], + request=CropSimulationRequestSerializer, + responses={200: code_response("CropSimulationYieldPredictionResponse", data=YieldPredictionSerializer())}, + ) + def post(self, request): + serializer = CropSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + payload = serializer.validated_data.copy() + + farm, error_response = self._get_farm(request, payload.get("farm_uuid")) + if error_response is not None: + return error_response + + ai_payload, plan_error = self._build_ai_payload_with_selected_plans( + farm, + irrigation_plan_uuid=payload.get("irrigation_plan_uuid"), + fertilization_plan_uuid=payload.get("fertilization_plan_uuid"), + ) + if plan_error is not None: + return plan_error + adapter_response = external_api_request("ai", self.ai_path, method="POST", payload=ai_payload) + + if adapter_response.status_code >= 400: + return self._error_response(adapter_response) + + return Response( + {"code": 200, "msg": "success", "data": self._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) + + +class GrowthSimulationView(APIView): + @extend_schema( + tags=["Crop Simulation"], + request=GrowthSimulationRequestSerializer, + responses={202: code_response("GrowthSimulationQueuedResponse", data=GrowthSimulationQueuedDataSerializer())}, + ) + def post(self, request): + serializer = GrowthSimulationRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + payload = serializer.validated_data.copy() + farm_uuid = payload.get("farm_uuid") + if farm_uuid is not None: + farm, error_response = CropSimulationBaseView._get_farm(request, farm_uuid) + if error_response is not None: + return error_response + payload["farm_uuid"] = str(farm.farm_uuid) + payload["plant_name"] = CropSimulationBaseView._get_first_farm_product_name(farm) + + adapter_response = external_api_request( + "ai", + "/api/crop-simulation/growth/", + method="POST", + payload=payload, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 202, "msg": "تسک شبیه سازی رشد در صف قرار گرفت.", "data": CropSimulationBaseView._extract_result(adapter_response.data)}, + status=status.HTTP_202_ACCEPTED, + ) + + +class GrowthSimulationStatusView(APIView): + @extend_schema( + tags=["Crop Simulation"], + parameters=[ + OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="شماره صفحه."), + OpenApiParameter( + name="page_size", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + required=False, + description="اندازه صفحه بین 1 تا 50.", + ), + ], + responses={200: code_response("GrowthSimulationStatusResponse", data=GrowthSimulationStatusDataSerializer())}, + ) + def get(self, request, task_id): + query = {} + if request.query_params.get("page"): + query["page"] = request.query_params.get("page") + if request.query_params.get("page_size"): + query["page_size"] = request.query_params.get("page_size") + + adapter_response = external_api_request( + "ai", + f"/api/crop-simulation/growth/{task_id}/status/", + method="GET", + query=query or None, + ) + + if adapter_response.status_code >= 400: + response_data = ( + adapter_response.data + if isinstance(adapter_response.data, dict) + else {"message": str(adapter_response.data)} + ) + return Response( + {"code": adapter_response.status_code, "msg": "error", "data": response_data}, + status=adapter_response.status_code, + ) + + return Response( + {"code": 200, "msg": "success", "data": CropSimulationBaseView._extract_result(adapter_response.data)}, + status=status.HTTP_200_OK, + ) diff --git a/Modules/SensorHub/.cursor/postman.mdc b/Modules/SensorHub/.cursor/postman.mdc new file mode 100644 index 0000000..fa8049d --- /dev/null +++ b/Modules/SensorHub/.cursor/postman.mdc @@ -0,0 +1,74 @@ +--- +alwaysApply: false +--- +# Backend API Architecture & Postman + +## 1. URL / Routing Architecture + +- **Root (config/urls.py):** API mounts under `api//` 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. `/`), then base `""` for list. + - Example pattern: + - `path("active/", View.as_view(), kwargs={"action": "active"})` + - `path("deactive/", View.as_view(), kwargs={"action": "deactive"})` + - `path("/", 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: `/postman/.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//...` (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. `/`) 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. diff --git a/Modules/SensorHub/.cursor/project.mdc b/Modules/SensorHub/.cursor/project.mdc new file mode 100644 index 0000000..8cc1be1 --- /dev/null +++ b/Modules/SensorHub/.cursor/project.mdc @@ -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). diff --git a/Modules/SensorHub/.cursor/test-rule.mdc b/Modules/SensorHub/.cursor/test-rule.mdc new file mode 100644 index 0000000..6a758c2 --- /dev/null +++ b/Modules/SensorHub/.cursor/test-rule.mdc @@ -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. diff --git a/Modules/SensorHub/.dockerignore b/Modules/SensorHub/.dockerignore new file mode 100644 index 0000000..34fb1e8 --- /dev/null +++ b/Modules/SensorHub/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +.git +__pycache__ +*.pyc +.venv +venv +*.egg-info +.pytest_cache +.coverage +htmlcov +*.log +media +staticfiles +.cursor diff --git a/Modules/SensorHub/.env.example b/Modules/SensorHub/.env.example new file mode 100644 index 0000000..ee42801 --- /dev/null +++ b/Modules/SensorHub/.env.example @@ -0,0 +1,20 @@ +# Django +SECRET_KEY=your-secret-key-change-in-production +DEBUG=1 +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database (MySQL) +DB_ENGINE=django.db.backends.mysql +DB_NAME=croplogic +DB_USER=croplogic +DB_PASSWORD=changeme +DB_HOST=db +DB_PORT=3306 +DB_ROOT_PASSWORD=root + +# Cassandra +CASSANDRA_ENABLED=1 +CASSANDRA_HOSTS=cassandra +CASSANDRA_PORT=9042 +CASSANDRA_KEYSPACE=sensor_hub +CASSANDRA_REPLICATION={'class': 'SimpleStrategy', 'replication_factor': 1} diff --git a/Modules/SensorHub/.gitea/workflows/sensor-hub.yml b/Modules/SensorHub/.gitea/workflows/sensor-hub.yml new file mode 100644 index 0000000..cc1208b --- /dev/null +++ b/Modules/SensorHub/.gitea/workflows/sensor-hub.yml @@ -0,0 +1,120 @@ +name: Sensor Hub Service CI/CD + +on: + push: + branches: [production] + paths: + - '**' + - '.gitea/workflows/sensor-hub.yml' + + pull_request: + branches: [production] + paths: + - '**' + - '.gitea/workflows/sensor-hub.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/SensorHub.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/SensorHub + git pull origin production + docker-compose -f docker-compose-prod.yaml down --remove-orphans + docker-compose -f docker-compose-prod.yaml up -d + EOF diff --git a/Modules/SensorHub/.github/workflows/sensor-hub.yml b/Modules/SensorHub/.github/workflows/sensor-hub.yml new file mode 100644 index 0000000..76f907e --- /dev/null +++ b/Modules/SensorHub/.github/workflows/sensor-hub.yml @@ -0,0 +1,72 @@ + name: Sensor Hub Service CI/CD + + on: + push: + branches: [main] + paths: + - 'sensor-hub/**' + - 'sensor-hub/.github/workflows/sensor-hub.yml' + pull_request: + branches: [main] + paths: + - 'sensor-hub/**' + - 'sensor-hub/.github/workflows/sensor-hub.yml' + + defaults: + run: + working-directory: sensor-hub + + 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: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-sensor-hub-${{ hashFiles('sensor-hub/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip-sensor-hub- + + - 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 + + deploy: + name: Deploy Sensor Hub Service + needs: test + 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/sensor-hub + git pull origin main + sudo systemctl restart sensor-hub diff --git a/Modules/SensorHub/.gitignore b/Modules/SensorHub/.gitignore new file mode 100644 index 0000000..5f9cd5a --- /dev/null +++ b/Modules/SensorHub/.gitignore @@ -0,0 +1,59 @@ +# 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 + +# Testing / Coverage +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ + +# OS +.DS_Store +Thumbs.db diff --git a/Modules/SensorHub/.gitmodules b/Modules/SensorHub/.gitmodules new file mode 100644 index 0000000..60b8775 --- /dev/null +++ b/Modules/SensorHub/.gitmodules @@ -0,0 +1,4 @@ +[submodule "Schemas"] + path = Schemas + url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git + branch = develop diff --git a/Modules/SensorHub/Dockerfile b/Modules/SensorHub/Dockerfile new file mode 100644 index 0000000..81b56c7 --- /dev/null +++ b/Modules/SensorHub/Dockerfile @@ -0,0 +1,45 @@ +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' \ +'' \ +'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm main contrib non-free non-free-firmware' \ +'deb [trusted=yes] https://mirror2.chabokan.net/debian-security bookworm-security main contrib non-free non-free-firmware' \ +'' \ +'deb http://mirror.iranserver.com/debian/ bookworm main contrib non-free non-free-firmware' \ +'deb-src http://mirror.iranserver.com/debian/ bookworm 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 . + +# Python 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.extra-index-url https://mirror2.chabokan.net/pypi/simple && \ + pip config --user set global.trusted-host package-mirror.liara.ir && \ + pip config --user set global.trusted-host mirror.cdn.ir && \ + pip config --user set global.trusted-host mirror-pypi.runflare.com + +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/Modules/SensorHub/Schemas/__init__.py b/Modules/SensorHub/Schemas/__init__.py new file mode 100644 index 0000000..78e30fd --- /dev/null +++ b/Modules/SensorHub/Schemas/__init__.py @@ -0,0 +1,57 @@ +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_prediction import CONTRACT as CROP_SIMULATION_YIELD_PREDICTION_CONTRACT +from .economy_overview import CONTRACT as ECONOMY_OVERVIEW_CONTRACT +from .farm_alerts import CONTRACTS as FARM_ALERTS_CONTRACTS +from .farm_data_upsert import CONTRACT as FARM_DATA_UPSERT_CONTRACT +from .farm_detail import CONTRACT as FARM_DETAIL_CONTRACT +from .farm_parameter import CONTRACT as FARM_PARAMETER_CONTRACT +from .fertilization_recommend import CONTRACT as FERTILIZATION_RECOMMEND_CONTRACT +from .irrigation_methods import CONTRACTS as IRRIGATION_METHOD_CONTRACTS +from .irrigation_recommend import CONTRACT as IRRIGATION_RECOMMEND_CONTRACT +from .irrigation_water_stress import CONTRACT as IRRIGATION_WATER_STRESS_CONTRACT +from .pest_disease import CONTRACTS as PEST_DISEASE_CONTRACTS +from .plant import CONTRACTS as PLANT_CONTRACTS +from .rag_chat import CONTRACT as RAG_CHAT_CONTRACT +from .soil_data import CONTRACTS as SOIL_DATA_CONTRACTS +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_farm_card import CONTRACT as WEATHER_FARM_CARD_CONTRACT +from .weather_water_need_prediction import CONTRACT as WEATHER_WATER_NEED_PREDICTION_CONTRACT + +ALL_ROUTE_CONTRACTS: list[RouteContract] = [ + RAG_CHAT_CONTRACT, + *FARM_ALERTS_CONTRACTS, + *SOIL_DATA_CONTRACTS, + SOILE_MOISTURE_HEATMAP_CONTRACT, + SOILE_HEALTH_SUMMARY_CONTRACT, + SOILE_ANOMALY_DETECTION_CONTRACT, + FARM_DATA_UPSERT_CONTRACT, + FARM_DETAIL_CONTRACT, + FARM_PARAMETER_CONTRACT, + WEATHER_FARM_CARD_CONTRACT, + WEATHER_WATER_NEED_PREDICTION_CONTRACT, + ECONOMY_OVERVIEW_CONTRACT, + *PLANT_CONTRACTS, + *PEST_DISEASE_CONTRACTS, + *IRRIGATION_METHOD_CONTRACTS, + IRRIGATION_RECOMMEND_CONTRACT, + IRRIGATION_WATER_STRESS_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_PREDICTION_CONTRACT, +] + +ROUTE_CONTRACTS: dict[str, RouteContract] = { + f'{contract.method} {contract.path}': contract + for contract in ALL_ROUTE_CONTRACTS +} + +__all__ = ['ALL_ROUTE_CONTRACTS', 'ROUTE_CONTRACTS', 'RouteContract'] diff --git a/Modules/SensorHub/Schemas/common.py b/Modules/SensorHub/Schemas/common.py new file mode 100644 index 0000000..8f01345 --- /dev/null +++ b/Modules/SensorHub/Schemas/common.py @@ -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 diff --git a/Modules/SensorHub/Schemas/crop_simulation_current_farm_chart.py b/Modules/SensorHub/Schemas/crop_simulation_current_farm_chart.py new file mode 100644 index 0000000..d43d1dc --- /dev/null +++ b/Modules/SensorHub/Schemas/crop_simulation_current_farm_chart.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/crop_simulation_growth.py b/Modules/SensorHub/Schemas/crop_simulation_growth.py new file mode 100644 index 0000000..642cf07 --- /dev/null +++ b/Modules/SensorHub/Schemas/crop_simulation_growth.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/crop_simulation_growth_status.py b/Modules/SensorHub/Schemas/crop_simulation_growth_status.py new file mode 100644 index 0000000..655d390 --- /dev/null +++ b/Modules/SensorHub/Schemas/crop_simulation_growth_status.py @@ -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//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__, +) diff --git a/Modules/SensorHub/Schemas/crop_simulation_harvest_prediction.py b/Modules/SensorHub/Schemas/crop_simulation_harvest_prediction.py new file mode 100644 index 0000000..78d363a --- /dev/null +++ b/Modules/SensorHub/Schemas/crop_simulation_harvest_prediction.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/crop_simulation_yield_prediction.py b/Modules/SensorHub/Schemas/crop_simulation_yield_prediction.py new file mode 100644 index 0000000..43f0784 --- /dev/null +++ b/Modules/SensorHub/Schemas/crop_simulation_yield_prediction.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/economy_overview.py b/Modules/SensorHub/Schemas/economy_overview.py new file mode 100644 index 0000000..362ed56 --- /dev/null +++ b/Modules/SensorHub/Schemas/economy_overview.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/farm_alerts.py b/Modules/SensorHub/Schemas/farm_alerts.py new file mode 100644 index 0000000..d7011ef --- /dev/null +++ b/Modules/SensorHub/Schemas/farm_alerts.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel + + +class FarmAlertsRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + query: str | None = None + + +class FarmAlertNotificationSchema(SchemaModel): + id: int | None = None + farm_uuid: str | None = None + endpoint: str | None = None + level: Literal['danger', 'warning', 'info'] | str + title: str + message: str + suggested_action: str | None = None + source_alert_id: str | None = None + source_metric_type: str | None = None + payload: JsonObject = Field(default_factory=dict) + created_at: str | None = None + updated_at: str | None = None + + +class FarmAlertsTimelineItem(SchemaModel): + timestamp: str | None = None + level: Literal['danger', 'warning', 'info'] | str + title: str + description: str | None = None + source_alert_id: str | None = None + source_metric_type: str | None = None + + +class FarmAlertsTrackerResponseData(SchemaModel): + farm_uuid: str + service_id: str | None = None + knowledge_base: str | None = None + tone_file: str | None = None + tracker: JsonObject = Field(default_factory=dict) + headline: str + overview: str | None = None + status_level: Literal['danger', 'warning', 'info'] | str + notifications: list[FarmAlertNotificationSchema] = Field(default_factory=list) + raw_llm_response: str | None = None + structured_context: JsonObject = Field(default_factory=dict) + + +class FarmAlertsTimelineResponseData(SchemaModel): + farm_uuid: str + service_id: str | None = None + knowledge_base: str | None = None + tone_file: str | None = None + tracker: JsonObject = Field(default_factory=dict) + headline: str + overview: str | None = None + timeline: list[FarmAlertsTimelineItem] = Field(default_factory=list) + notifications: list[FarmAlertNotificationSchema] = Field(default_factory=list) + raw_llm_response: str | None = None + structured_context: JsonObject = Field(default_factory=dict) + + +class FarmAlertsTrackerResponse(ApiEnvelope[FarmAlertsTrackerResponseData]): + pass + + +class FarmAlertsTimelineResponse(ApiEnvelope[FarmAlertsTimelineResponseData]): + pass + + +CONTRACTS = [ + RouteContract( + method='POST', + path='/api/farm-alerts/tracker/', + request_model=FarmAlertsRequest.__name__, + response_model=FarmAlertsTrackerResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/farm-alerts/timeline/', + request_model=FarmAlertsRequest.__name__, + response_model=FarmAlertsTimelineResponse.__name__, + ), +] diff --git a/Modules/SensorHub/Schemas/farm_data_upsert.py b/Modules/SensorHub/Schemas/farm_data_upsert.py new file mode 100644 index 0000000..ebfe7cd --- /dev/null +++ b/Modules/SensorHub/Schemas/farm_data_upsert.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/farm_detail.py b/Modules/SensorHub/Schemas/farm_detail.py new file mode 100644 index 0000000..98f24ed --- /dev/null +++ b/Modules/SensorHub/Schemas/farm_detail.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel + +HTTP_METHOD = 'GET' +ROUTE_PATH = '/api/farm-data//detail/' + + +class FarmDetailRequest(SchemaModel): + farm_uuid: str + + +class FarmCenterLocationSchema(SchemaModel): + id: int + lat: float + lon: float + farm_boundary: JsonObject = Field(default_factory=dict) + + +class WeatherForecastDetailSchema(SchemaModel): + id: int + forecast_date: str | None = None + temperature_min: float | None = None + temperature_max: float | None = None + temperature_mean: float | None = None + precipitation: float | None = None + precipitation_probability: float | None = None + humidity_mean: float | None = None + wind_speed_max: float | None = None + et0: float | None = None + weather_code: int | None = None + + +class FarmSoilDepthSchema(SchemaModel): + depth_label: str + bdod: float | None = None + cec: float | None = None + cfvo: float | None = None + clay: float | None = None + nitrogen: float | None = None + ocd: float | None = None + ocs: float | None = None + phh2o: float | None = None + sand: float | None = None + silt: float | None = None + soc: float | None = None + wv0010: float | None = None + wv0033: float | None = None + wv1500: float | None = None + + +class FarmSoilPayloadSchema(SchemaModel): + resolved_metrics: JsonObject = Field(default_factory=dict) + metric_sources: JsonObject = Field(default_factory=dict) + depths: list[FarmSoilDepthSchema] = Field(default_factory=list) + + +class FarmPlantSchema(SchemaModel): + id: int + name: str + light: str | None = None + watering: str | None = None + soil: str | None = None + temperature: str | None = None + growth_stage: str | None = None + planting_season: str | None = None + harvest_time: str | None = None + spacing: str | None = None + fertilizer: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class FarmIrrigationMethodSchema(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 FarmDetailResponseData(SchemaModel): + center_location: FarmCenterLocationSchema + weather: WeatherForecastDetailSchema | None = None + sensor_payload: JsonObject = Field(default_factory=dict) + soil: FarmSoilPayloadSchema + plant_ids: list[int] = Field(default_factory=list) + plants: list[FarmPlantSchema] = Field(default_factory=list) + irrigation_method_id: int | None = None + irrigation_method: FarmIrrigationMethodSchema | None = None + created_at: str | None = None + updated_at: str | None = None + + +class FarmDetailResponse(ApiEnvelope[FarmDetailResponseData]): + pass + + +CONTRACT = RouteContract( + method=HTTP_METHOD, + path=ROUTE_PATH, + request_model=FarmDetailRequest.__name__, + response_model=FarmDetailResponse.__name__, +) diff --git a/Modules/SensorHub/Schemas/farm_parameter.py b/Modules/SensorHub/Schemas/farm_parameter.py new file mode 100644 index 0000000..1f82975 --- /dev/null +++ b/Modules/SensorHub/Schemas/farm_parameter.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel + +HTTP_METHOD = 'POST' +ROUTE_PATH = '/api/farm-data/parameters/' + + +class FarmParameterRequest(SchemaModel): + sensor_key: str = 'sensor-7-1' + code: str + name_fa: str + unit: str | None = '' + data_type: str | None = 'float' + metadata: JsonObject = Field(default_factory=dict) + + +class FarmParameterResponseData(SchemaModel): + id: int + sensor_key: str + code: str + name_fa: str + unit: str | None = None + data_type: str | None = None + metadata: JsonObject = Field(default_factory=dict) + created_at: str | None = None + action: str + + +class FarmParameterResponse(ApiEnvelope[FarmParameterResponseData]): + pass + + +CONTRACT = RouteContract( + method=HTTP_METHOD, + path=ROUTE_PATH, + request_model=FarmParameterRequest.__name__, + response_model=FarmParameterResponse.__name__, +) diff --git a/Modules/SensorHub/Schemas/fertilization_recommend.py b/Modules/SensorHub/Schemas/fertilization_recommend.py new file mode 100644 index 0000000..cef7189 --- /dev/null +++ b/Modules/SensorHub/Schemas/fertilization_recommend.py @@ -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/fertilization/recommend/' + + +class FertilizationRecommendRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + plant_name: str | None = None + growth_stage: str | None = None + query: str | None = None + + +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): + 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__, +) diff --git a/Modules/SensorHub/Schemas/irrigation_list.py b/Modules/SensorHub/Schemas/irrigation_list.py new file mode 100644 index 0000000..4548233 --- /dev/null +++ b/Modules/SensorHub/Schemas/irrigation_list.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pydantic import Field + +from .common import ApiEnvelope, 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(ApiEnvelope[list[IrrigationMethodSchema]]): + pass + + +CONTRACT = RouteContract( + method=HTTP_METHOD, + path=ROUTE_PATH, + request_model=IrrigationListRequest.__name__, + response_model=IrrigationListResponse.__name__, +) diff --git a/Modules/SensorHub/Schemas/irrigation_methods.py b/Modules/SensorHub/Schemas/irrigation_methods.py new file mode 100644 index 0000000..207da2e --- /dev/null +++ b/Modules/SensorHub/Schemas/irrigation_methods.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pydantic import Field + +from .common import ApiEnvelope, EmptyRequest, JsonObject, JsonValue, RouteContract, SchemaModel + + +class IrrigationMethodPayload(SchemaModel): + 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 + + +class IrrigationMethodPartialPayload(SchemaModel): + name: str | None = None + 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 + + +class IrrigationMethodSchema(IrrigationMethodPayload): + id: int + created_at: str | None = None + updated_at: str | None = None + + +class IrrigationMethodDetailRequest(SchemaModel): + pk: int + + +class IrrigationMethodListResponse(ApiEnvelope[list[IrrigationMethodSchema]]): + pass + + +class IrrigationMethodDetailResponse(ApiEnvelope[IrrigationMethodSchema]): + pass + + +class IrrigationMethodDeleteResponse(ApiEnvelope[JsonValue | None]): + pass + + +CONTRACTS = [ + RouteContract( + method='GET', + path='/api/irrigation/', + request_model=EmptyRequest.__name__, + response_model=IrrigationMethodListResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/irrigation/', + request_model=IrrigationMethodPayload.__name__, + response_model=IrrigationMethodDetailResponse.__name__, + ), + RouteContract( + method='GET', + path='/api/irrigation//', + request_model=IrrigationMethodDetailRequest.__name__, + response_model=IrrigationMethodDetailResponse.__name__, + ), + RouteContract( + method='PUT', + path='/api/irrigation//', + request_model=IrrigationMethodPayload.__name__, + response_model=IrrigationMethodDetailResponse.__name__, + ), + RouteContract( + method='PATCH', + path='/api/irrigation//', + request_model=IrrigationMethodPartialPayload.__name__, + response_model=IrrigationMethodDetailResponse.__name__, + ), + RouteContract( + method='DELETE', + path='/api/irrigation//', + request_model=IrrigationMethodDetailRequest.__name__, + response_model=IrrigationMethodDeleteResponse.__name__, + ), +] diff --git a/Modules/SensorHub/Schemas/irrigation_recommend.py b/Modules/SensorHub/Schemas/irrigation_recommend.py new file mode 100644 index 0000000..a8b6653 --- /dev/null +++ b/Modules/SensorHub/Schemas/irrigation_recommend.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/irrigation_water_stress.py b/Modules/SensorHub/Schemas/irrigation_water_stress.py new file mode 100644 index 0000000..38b4be4 --- /dev/null +++ b/Modules/SensorHub/Schemas/irrigation_water_stress.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, RouteContract, SchemaModel + +HTTP_METHOD = 'POST' +ROUTE_PATH = '/api/irrigation/water-stress/' + + +class IrrigationWaterStressRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + + +class IrrigationWaterStressResponseData(SchemaModel): + farm_uuid: str + waterStressIndex: int | float + level: Literal['low', 'medium', 'high'] | str + sourceMetric: JsonObject = Field(default_factory=dict) + + +class IrrigationWaterStressResponse(ApiEnvelope[IrrigationWaterStressResponseData]): + pass + + +CONTRACT = RouteContract( + method=HTTP_METHOD, + path=ROUTE_PATH, + request_model=IrrigationWaterStressRequest.__name__, + response_model=IrrigationWaterStressResponse.__name__, +) diff --git a/Modules/SensorHub/Schemas/pest_disease.py b/Modules/SensorHub/Schemas/pest_disease.py new file mode 100644 index 0000000..52c258a --- /dev/null +++ b/Modules/SensorHub/Schemas/pest_disease.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel + + +class PestDiseaseDetectionRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + plant_name: str | None = None + query: str | None = None + image_urls: list[str] = Field(default_factory=list) + image: str | None = None + images: list[str] = Field(default_factory=list) + + +class PestDiseaseDetectionResponseData(SchemaModel): + has_issue: bool + category: Literal['no_issue', 'pest', 'disease', 'nutrient_stress', 'abiotic_stress', 'unknown'] | str + confidence: float | None = None + severity: Literal['low', 'medium', 'high'] | str + summary: str + detected_signs: list[str] = Field(default_factory=list) + possible_causes: list[str] = Field(default_factory=list) + immediate_actions: list[str] = Field(default_factory=list) + reasoning: list[str] = Field(default_factory=list) + farm_uuid: str | None = None + knowledge_base: str | None = None + tone_file: str | None = None + raw_response: str | None = None + + +class PestDiseaseRiskRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + plant_name: str | None = None + growth_stage: str | None = None + query: str | None = None + + +class RiskBlock(SchemaModel): + score: float | None = None + level: Literal['low', 'medium', 'high'] | str | None = None + likely_conditions: list[str] = Field(default_factory=list) + reasoning: list[str] = Field(default_factory=list) + statsLabel: str | None = None + + +class PestDiseaseRiskResponseData(SchemaModel): + summary: str + forecast_window: str | None = None + overall_risk: Literal['low', 'medium', 'high'] | str + disease_risk: RiskBlock = Field(default_factory=RiskBlock) + pest_risk: RiskBlock = Field(default_factory=RiskBlock) + key_drivers: list[str] = Field(default_factory=list) + recommended_actions: list[str] = Field(default_factory=list) + farm_context: JsonObject = Field(default_factory=dict) + farm_uuid: str | None = None + knowledge_base: str | None = None + tone_file: str | None = None + raw_response: str | None = None + + +class PestDiseaseRiskSummaryRequest(SchemaModel): + farm_uuid: UUID + sensor_uuid: UUID | None = None + + +class PestDiseaseRiskSummaryDrivers(SchemaModel): + keyDrivers: list[str] = Field(default_factory=list) + summary: str | None = None + forecastWindow: str | None = None + source: str | None = None + + +class PestDiseaseRiskSummaryResponseData(SchemaModel): + farm_uuid: str + diseaseRisk: RiskBlock = Field(default_factory=RiskBlock) + pestRisk: RiskBlock = Field(default_factory=RiskBlock) + drivers: PestDiseaseRiskSummaryDrivers = Field(default_factory=PestDiseaseRiskSummaryDrivers) + + +class PestDiseaseDetectionResponse(ApiEnvelope[PestDiseaseDetectionResponseData]): + pass + + +class PestDiseaseRiskResponse(ApiEnvelope[PestDiseaseRiskResponseData]): + pass + + +class PestDiseaseRiskSummaryResponse(ApiEnvelope[PestDiseaseRiskSummaryResponseData]): + pass + + +CONTRACTS = [ + RouteContract( + method='POST', + path='/api/pest-disease/detect/', + request_model=PestDiseaseDetectionRequest.__name__, + response_model=PestDiseaseDetectionResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/pest-disease/risk/', + request_model=PestDiseaseRiskRequest.__name__, + response_model=PestDiseaseRiskResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/pest-disease/risk-summary/', + request_model=PestDiseaseRiskSummaryRequest.__name__, + response_model=PestDiseaseRiskSummaryResponse.__name__, + ), +] diff --git a/Modules/SensorHub/Schemas/plant.py b/Modules/SensorHub/Schemas/plant.py new file mode 100644 index 0000000..64d6879 --- /dev/null +++ b/Modules/SensorHub/Schemas/plant.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pydantic import Field + +from .common import ApiEnvelope, EmptyRequest, JsonObject, JsonValue, RouteContract, SchemaModel + + +class PlantPayload(SchemaModel): + name: str + light: str | None = None + watering: str | None = None + soil: str | None = None + temperature: str | None = None + growth_stage: str | None = None + planting_season: str | None = None + harvest_time: str | None = None + spacing: str | None = None + fertilizer: str | None = None + + +class PlantPartialPayload(SchemaModel): + name: str | None = None + light: str | None = None + watering: str | None = None + soil: str | None = None + temperature: str | None = None + growth_stage: str | None = None + planting_season: str | None = None + harvest_time: str | None = None + spacing: str | None = None + fertilizer: str | None = None + + +class PlantRecord(PlantPayload): + id: int + created_at: str | None = None + updated_at: str | None = None + + +class PlantListResponse(ApiEnvelope[list[PlantRecord]]): + pass + + +class PlantDetailResponse(ApiEnvelope[PlantRecord]): + pass + + +class PlantDeleteResponse(ApiEnvelope[JsonValue | None]): + pass + + +class PlantDetailRequest(SchemaModel): + pk: int + + +class PlantFetchInfoRequest(SchemaModel): + name: str + + +class PlantFetchInfoResponse(ApiEnvelope[JsonObject]): + pass + + +CONTRACTS = [ + RouteContract( + method='GET', + path='/api/plants/', + request_model=EmptyRequest.__name__, + response_model=PlantListResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/plants/', + request_model=PlantPayload.__name__, + response_model=PlantDetailResponse.__name__, + ), + RouteContract( + method='GET', + path='/api/plants//', + request_model=PlantDetailRequest.__name__, + response_model=PlantDetailResponse.__name__, + ), + RouteContract( + method='PUT', + path='/api/plants//', + request_model=PlantPayload.__name__, + response_model=PlantDetailResponse.__name__, + ), + RouteContract( + method='PATCH', + path='/api/plants//', + request_model=PlantPartialPayload.__name__, + response_model=PlantDetailResponse.__name__, + ), + RouteContract( + method='DELETE', + path='/api/plants//', + request_model=PlantDetailRequest.__name__, + response_model=PlantDeleteResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/plants/fetch-info/', + request_model=PlantFetchInfoRequest.__name__, + response_model=PlantFetchInfoResponse.__name__, + ), +] diff --git a/Modules/SensorHub/Schemas/rag_chat.py b/Modules/SensorHub/Schemas/rag_chat.py new file mode 100644 index 0000000..a197d0b --- /dev/null +++ b/Modules/SensorHub/Schemas/rag_chat.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/soil_data.py b/Modules/SensorHub/Schemas/soil_data.py new file mode 100644 index 0000000..4affb77 --- /dev/null +++ b/Modules/SensorHub/Schemas/soil_data.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import Field + +from .common import ApiEnvelope, JsonObject, JsonValue, RouteContract, SchemaModel + + +class SoilDataCoordinatesRequest(SchemaModel): + lat: float + lon: float + + +class SoilDepthDataSchema(SchemaModel): + depth_label: str + bdod: float | None = None + cec: float | None = None + cfvo: float | None = None + clay: float | None = None + nitrogen: float | None = None + ocd: float | None = None + ocs: float | None = None + phh2o: float | None = None + sand: float | None = None + silt: float | None = None + soc: float | None = None + wv0010: float | None = None + wv0033: float | None = None + wv1500: float | None = None + + +class SoilLocationPayload(SchemaModel): + source: str + id: int + lon: float + lat: float + depths: list[SoilDepthDataSchema] = Field(default_factory=list) + + +class SoilTaskQueuedResponseData(SchemaModel): + source: str = 'task' + task_id: str + lon: float + lat: float + status_url: str | None = None + + +class SoilTaskStatusResponseData(SchemaModel): + task_id: str + status: str + message: str | None = None + progress: JsonObject = Field(default_factory=dict) + result: JsonValue | None = None + error: str | None = None + + +class NdviHealthRequest(SchemaModel): + farm_uuid: UUID + + +class NdviHealthDataItem(SchemaModel): + title: str + value: JsonValue | None = None + color: str + icon: str + + +class NdviHealthResponseData(SchemaModel): + ndviIndex: float | None = None + mean_ndvi: float | None = None + ndvi_map: JsonObject = Field(default_factory=dict) + vegetation_health_class: str | None = None + observation_date: str | None = None + satellite_source: str | None = None + healthData: list[NdviHealthDataItem] = Field(default_factory=list) + + +class SoilDataResponse(ApiEnvelope[SoilLocationPayload]): + pass + + +class SoilTaskQueuedResponse(ApiEnvelope[SoilTaskQueuedResponseData]): + pass + + +class SoilTaskStatusResponse(ApiEnvelope[SoilTaskStatusResponseData]): + pass + + +class NdviHealthResponse(ApiEnvelope[NdviHealthResponseData]): + pass + + +CONTRACTS = [ + RouteContract( + method='GET', + path='/api/soil-data/', + request_model=SoilDataCoordinatesRequest.__name__, + response_model=SoilDataResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/soil-data/', + request_model=SoilDataCoordinatesRequest.__name__, + response_model=SoilDataResponse.__name__, + ), + RouteContract( + method='GET', + path='/api/soil-data/tasks//status/', + request_model='SoilTaskStatusRequest', + response_model=SoilTaskStatusResponse.__name__, + ), + RouteContract( + method='POST', + path='/api/soil-data/ndvi-health/', + request_model=NdviHealthRequest.__name__, + response_model=NdviHealthResponse.__name__, + ), +] + + +class SoilTaskStatusRequest(SchemaModel): + task_id: str diff --git a/Modules/SensorHub/Schemas/soile_anomaly_detection.py b/Modules/SensorHub/Schemas/soile_anomaly_detection.py new file mode 100644 index 0000000..6e4051c --- /dev/null +++ b/Modules/SensorHub/Schemas/soile_anomaly_detection.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/soile_health_summary.py b/Modules/SensorHub/Schemas/soile_health_summary.py new file mode 100644 index 0000000..69c2676 --- /dev/null +++ b/Modules/SensorHub/Schemas/soile_health_summary.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/soile_moisture_heatmap.py b/Modules/SensorHub/Schemas/soile_moisture_heatmap.py new file mode 100644 index 0000000..b360016 --- /dev/null +++ b/Modules/SensorHub/Schemas/soile_moisture_heatmap.py @@ -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__, +) diff --git a/Modules/SensorHub/Schemas/weather_farm_card.py b/Modules/SensorHub/Schemas/weather_farm_card.py new file mode 100644 index 0000000..4141639 --- /dev/null +++ b/Modules/SensorHub/Schemas/weather_farm_card.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from uuid import UUID + +from pydantic import Field + +from .common import ApiEnvelope, RouteContract, SchemaModel + +HTTP_METHOD = 'POST' +ROUTE_PATH = '/api/weather/farm-card/' + + +class WeatherFarmCardRequest(SchemaModel): + farm_uuid: UUID + + +class WeatherChartData(SchemaModel): + labels: list[str] = Field(default_factory=list) + series: list[list[float]] = Field(default_factory=list) + + +class WeatherFarmCardResponseData(SchemaModel): + condition: str + temperature: float | int + unit: str + humidity: float | int + windSpeed: float | int + windUnit: str + chartData: WeatherChartData = Field(default_factory=WeatherChartData) + + +class WeatherFarmCardResponse(ApiEnvelope[WeatherFarmCardResponseData]): + pass + + +CONTRACT = RouteContract( + method=HTTP_METHOD, + path=ROUTE_PATH, + request_model=WeatherFarmCardRequest.__name__, + response_model=WeatherFarmCardResponse.__name__, +) diff --git a/Modules/SensorHub/Schemas/weather_water_need_prediction.py b/Modules/SensorHub/Schemas/weather_water_need_prediction.py new file mode 100644 index 0000000..2042cff --- /dev/null +++ b/Modules/SensorHub/Schemas/weather_water_need_prediction.py @@ -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__, +) diff --git a/Modules/SensorHub/config/__init__.py b/Modules/SensorHub/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/SensorHub/config/asgi.py b/Modules/SensorHub/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/Modules/SensorHub/config/asgi.py @@ -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() diff --git a/Modules/SensorHub/config/settings.py b/Modules/SensorHub/config/settings.py new file mode 100644 index 0000000..467b490 --- /dev/null +++ b/Modules/SensorHub/config/settings.py @@ -0,0 +1,105 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") +DEBUG = os.environ.get("DEBUG", "0") == "1" +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", + "ingest", + "rest_framework", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "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", +] + +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", "sensor_hub"), + "USER": os.environ.get("DB_USER", "sensor_hub"), + "PASSWORD": os.environ.get("DB_PASSWORD", ""), + "HOST": os.environ.get("DB_HOST", "127.0.0.1"), + "PORT": os.environ.get("DB_PORT", "3306"), + "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" + +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", + ], +} + +if "rest_framework_simplejwt" in INSTALLED_APPS: + REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + ] + +CORS_ALLOW_ALL_ORIGINS = DEBUG diff --git a/Modules/SensorHub/config/urls.py b/Modules/SensorHub/config/urls.py new file mode 100644 index 0000000..4d33509 --- /dev/null +++ b/Modules/SensorHub/config/urls.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.urls import include, path + +from ingest.views import SensorSimulatorAppView + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", SensorSimulatorAppView.as_view(), name="home"), + path("api/ingest/", include("ingest.urls")), +] diff --git a/Modules/SensorHub/config/wsgi.py b/Modules/SensorHub/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/Modules/SensorHub/config/wsgi.py @@ -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() diff --git a/Modules/SensorHub/docker-compose-prod.yaml b/Modules/SensorHub/docker-compose-prod.yaml new file mode 100644 index 0000000..d971535 --- /dev/null +++ b/Modules/SensorHub/docker-compose-prod.yaml @@ -0,0 +1,63 @@ +services: + db: + image: docker.iranserver.com/mysql:8.0 + container_name: sensor-hub-db + restart: always + environment: + MYSQL_DATABASE: ${DB_NAME:-sensor_hub} + MYSQL_USER: ${DB_USER:-sensor_hub} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - sensor_hub_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - sensor-network + + web: + build: + context: . + dockerfile: Dockerfile + container_name: sensor-hub-web + restart: always + ports: + - "8010:8000" + env_file: + - .env + environment: + DB_HOST: db + depends_on: + db: + condition: service_healthy + networks: + - sensor-network + + sensor-sender: + build: + context: . + dockerfile: Dockerfile + container_name: sensor-hub-sender + command: python manage.py send_sensor_data + restart: always + env_file: + - .env + environment: + DB_HOST: db + depends_on: + web: + condition: service_started + db: + condition: service_healthy + networks: + - sensor-network + +volumes: + sensor_hub_mysql_data: + +networks: + sensor-network: + driver: bridge diff --git a/Modules/SensorHub/docker-compose.yaml b/Modules/SensorHub/docker-compose.yaml new file mode 100644 index 0000000..d6eee6f --- /dev/null +++ b/Modules/SensorHub/docker-compose.yaml @@ -0,0 +1,87 @@ +# Development: volumes mount source so code updates apply without rebuild +name: sensor-hub + +services: + cassandra: + image: docker-mirror.liara.ir/cassandra:5.0 + container_name: sensor-hub-cassandra + ports: + - "9042:9042" + volumes: + - sensor_hub_cassandra_data:/var/lib/cassandra + healthcheck: + test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE KEYSPACES' || exit 1"] + interval: 20s + timeout: 10s + retries: 10 + + db: + image: docker-mirror.liara.ir/mysql:8.0 + container_name: sensor-hub-db + environment: + MYSQL_DATABASE: ${DB_NAME:-sensor_hub} + MYSQL_USER: ${DB_USER:-sensor_hub} + MYSQL_PASSWORD: ${DB_PASSWORD:-changeme} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme} + volumes: + - sensor_hub_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"] + interval: 5s + timeout: 5s + retries: 5 + + phpmyadmin: + image: docker-mirror.liara.ir/phpmyadmin:latest + container_name: sensor-hub-phpmyadmin + environment: + PMA_HOST: db + PMA_PORT: 3306 + UPLOAD_LIMIT: 64M + ports: + - "8081:80" + depends_on: + db: + condition: service_healthy + + web: + build: . + container_name: sensor-hub-web + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/app + ports: + - "8010:8000" + env_file: + - .env + environment: + DB_HOST: db + CASSANDRA_HOSTS: cassandra + depends_on: + db: + condition: service_healthy + cassandra: + condition: service_started + + sensor-sender: + build: . + container_name: sensor-hub-sender + command: python manage.py send_sensor_data + volumes: + - .:/app + env_file: + - .env + environment: + DB_HOST: db + CASSANDRA_HOSTS: cassandra + depends_on: + web: + condition: service_started + db: + condition: service_healthy + cassandra: + condition: service_started + +volumes: + sensor_hub_mysql_data: + sensor_hub_cassandra_data: diff --git a/Modules/SensorHub/ingest/__init__.py b/Modules/SensorHub/ingest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/SensorHub/ingest/constants.py b/Modules/SensorHub/ingest/constants.py new file mode 100644 index 0000000..41f05fb --- /dev/null +++ b/Modules/SensorHub/ingest/constants.py @@ -0,0 +1,14 @@ +API_TARGET_URL = "http://backend-web:8000" +API_KEY = "12345" +REQUEST_INTERVAL_SECONDS = 10 + +STATIC_SENSOR_PAYLOAD = { + "uuid": "11111111111111111111", + "soil_moisture": 42.5, + "soil_temperature": 24.3, + "soil_ph": 6.8, + "soil_ec": 1.4, + "nitrogen": 32, + "phosphorus": 18, + "potassium": 27, +} diff --git a/Modules/SensorHub/ingest/management/__init__.py b/Modules/SensorHub/ingest/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/SensorHub/ingest/management/commands/__init__.py b/Modules/SensorHub/ingest/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Modules/SensorHub/ingest/management/commands/send_sensor_data.py b/Modules/SensorHub/ingest/management/commands/send_sensor_data.py new file mode 100644 index 0000000..9489807 --- /dev/null +++ b/Modules/SensorHub/ingest/management/commands/send_sensor_data.py @@ -0,0 +1,72 @@ +import json +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from django.core.management.base import BaseCommand + +from ingest.constants import API_KEY, API_TARGET_URL, REQUEST_INTERVAL_SECONDS, STATIC_SENSOR_PAYLOAD + + +class Command(BaseCommand): + help = "Send the static soil sensor payload to the upstream API every 10 seconds." + + def add_arguments(self, parser): + parser.add_argument( + "--once", + action="store_true", + help="Send the request once and exit.", + ) + + def handle(self, *args, **options): + run_once = options["once"] + + self.stdout.write( + self.style.SUCCESS( + f"Starting sensor sender -> {API_TARGET_URL} (interval: {REQUEST_INTERVAL_SECONDS}s)" + ) + ) + + while True: + self.send_payload() + if run_once: + break + time.sleep(REQUEST_INTERVAL_SECONDS) + + def send_payload(self): + body = json.dumps(STATIC_SENSOR_PAYLOAD).encode("utf-8") + request = Request( + API_TARGET_URL, + data=body, + headers={ + "Content-Type": "application/json", + "api_key": API_KEY, + }, + method="POST", + ) + + try: + with urlopen(request, timeout=15) as response: + response_body = response.read().decode("utf-8", errors="replace") + self.stdout.write( + self.style.SUCCESS( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Sent payload successfully - status {response.status}" + ) + ) + if response_body: + self.stdout.write(response_body) + except HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + self.stderr.write( + self.style.ERROR( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Upstream error - status {exc.code}" + ) + ) + if error_body: + self.stderr.write(error_body) + except URLError as exc: + self.stderr.write( + self.style.ERROR( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Connection error - {exc.reason}" + ) + ) diff --git a/Modules/SensorHub/ingest/templates/ingest/index.html b/Modules/SensorHub/ingest/templates/ingest/index.html new file mode 100644 index 0000000..8e14672 --- /dev/null +++ b/Modules/SensorHub/ingest/templates/ingest/index.html @@ -0,0 +1,166 @@ + + + + + + شبیه ساز سنسور خاک + + + +
+
+

ارسال استاتیک داده سنسور خاک

+

+ این صفحه بدون هیچ اتصال به دیتابیس، یک payload استاتیک از داده های سنسور خاک را با متد POST + و هدر api_key به API مقصد ارسال می کند. +

+
+ +
+
+ + + + + + + + +

+ فیلدها شامل uuid، رطوبت خاک، دمای خاک، pH، EC، نیتروژن، فسفر و پتاسیم هستند و فعلا همه به صورت استاتیک تعریف شده اند. +

+ +
+ +
+
+ +
+ +
هنوز درخواستی ارسال نشده است.
+

+ در پاسخ، payload ارسالی، هدرهای ارسال شده و پاسخ API مقصد نمایش داده می شود. +

+
+
+
+ + + + diff --git a/Modules/SensorHub/ingest/urls.py b/Modules/SensorHub/ingest/urls.py new file mode 100644 index 0000000..1a17e56 --- /dev/null +++ b/Modules/SensorHub/ingest/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import ForwardSensorDataView + +urlpatterns = [ + path("forward/", ForwardSensorDataView.as_view(), name="forward-sensor-data"), +] diff --git a/Modules/SensorHub/ingest/views.py b/Modules/SensorHub/ingest/views.py new file mode 100644 index 0000000..3db564e --- /dev/null +++ b/Modules/SensorHub/ingest/views.py @@ -0,0 +1,98 @@ +import json +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from django.http import HttpResponse, JsonResponse +from django.shortcuts import render +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.utils.decorators import method_decorator + +from ingest.constants import API_KEY, API_TARGET_URL, STATIC_SENSOR_PAYLOAD + + +class SensorSimulatorAppView(View): + def get(self, request): + return render( + request, + "ingest/index.html", + { + "default_payload": json.dumps(STATIC_SENSOR_PAYLOAD, indent=2), + "default_url": API_TARGET_URL, + "default_api_key": API_KEY, + }, + ) + + +@method_decorator(csrf_exempt, name="dispatch") +class ForwardSensorDataView(View): + def post(self, request): + target_url = request.POST.get("target_url", "").strip() + api_key = request.POST.get("api_key", "").strip() + + if not target_url: + return JsonResponse({"error": "target_url is required"}, status=400) + if not api_key: + return JsonResponse({"error": "api_key is required"}, status=400) + + payload = STATIC_SENSOR_PAYLOAD + body = json.dumps(payload).encode("utf-8") + outbound_request = Request( + target_url, + data=body, + headers={ + "Content-Type": "application/json", + "api_key": api_key, + }, + method="POST", + ) + + try: + with urlopen(outbound_request, timeout=15) as response: + response_body = response.read().decode("utf-8") + content_type = response.headers.get("Content-Type", "") + parsed_body = response_body + if "application/json" in content_type and response_body: + parsed_body = json.loads(response_body) + + return JsonResponse( + { + "status": response.status, + "sent_headers": { + "Content-Type": "application/json", + "api_key": api_key, + }, + "sent_payload": payload, + "response": parsed_body, + } + ) + except HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + return JsonResponse( + { + "error": "upstream returned an error", + "status": exc.code, + "sent_headers": { + "Content-Type": "application/json", + "api_key": api_key, + }, + "sent_payload": payload, + "response": error_body, + }, + status=502, + ) + except URLError as exc: + return JsonResponse( + { + "error": "could not reach upstream api", + "details": str(exc.reason), + "sent_headers": { + "Content-Type": "application/json", + "api_key": api_key, + }, + "sent_payload": payload, + }, + status=502, + ) + + return HttpResponse(status=500) diff --git a/Modules/SensorHub/manage.py b/Modules/SensorHub/manage.py new file mode 100644 index 0000000..d28672e --- /dev/null +++ b/Modules/SensorHub/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/Modules/SensorHub/requirements.txt b/Modules/SensorHub/requirements.txt new file mode 100644 index 0000000..e7d1cd8 --- /dev/null +++ b/Modules/SensorHub/requirements.txt @@ -0,0 +1,16 @@ +Django>=5.0,<5.2 +djangorestframework>=3.14,<3.16 +djangorestframework-simplejwt>=5.3,<5.4 +django-cors-headers>=4.3,<4.5 +drf-spectacular>=0.27,<0.28 +drf-spectacular-sidecar>=2024.7.1,<2025 +celery[redis]>=5.3,<5.4 +redis>=5.0,<5.1 + +mysqlclient>=2.2,<2.3 +gunicorn>=22,<23 +python-dotenv>=1.0,<1.1 + + + +cassandra-driver>=3.29,<3.30 diff --git a/PROJECT_WEAKNESSES_AUDIT_FA.md b/PROJECT_WEAKNESSES_AUDIT_FA.md new file mode 100644 index 0000000..0d2964d --- /dev/null +++ b/PROJECT_WEAKNESSES_AUDIT_FA.md @@ -0,0 +1,409 @@ +# گزارش ممیزی ضعف‌های حل‌نشده پروژه + +این سند فقط روی **مشکلات حل‌نشده و باقی‌مانده** تمرکز دارد و مواردی را که در auditهای قبلی رفع شده‌اند، از فهرست ضعف‌های فعال خارج می‌کند. + +مبنای این گزارش: + +- کد فعلی پروژه +- سند `Ai/API_RELIABILITY_AUDIT_FA.md` +- سند `Backend/AI_ROUTE_CONNECTION_AUDIT.md` +- سند قبلی `PROJECT_WEAKNESSES_AUDIT_FA.md` + +--- + +## خلاصه مدیریتی + +مهم‌ترین ضعف‌های حل‌نشده فعلی: + +1. هنوز بین `docs`، `spec`، routeهای واقعی و ownership سرویس‌ها چند ناهماهنگی مهم وجود دارد. +2. بخشی از flowها هنوز `contract-only`، `partially_implemented` یا `transitional` هستند. +3. در چند سرویس مهم هنوز الگوی `silent fallback` و بازگشت `{}` یا `[]` دیده می‌شود. +4. بعضی adapterهای mock هنوز در runtime code حضور دارند و می‌توانند باعث false confidence شوند. +5. چند فایل کلیدی backend و AI هنوز بیش از حد بزرگ و چندمسئولیتی هستند. +6. migration معماری `farm_data` هنوز کامل نشده و بعضی relationهای transitional باقی مانده‌اند. +7. بخشی از تست‌ها هنوز بیشتر contract/proxy-oriented هستند تا behavior واقعی production. + +--- + +## مواردی که دیگر ضعف فعال محسوب نمی‌شوند + +این موارد در گزارش حاضر **حل‌شده** در نظر گرفته می‌شوند و جزو مشکلات باز نیستند: + +- provider پیش‌فرض AI در `Ai/config/settings.py` دیگر روی `mock` نیست. +- استفاده از `mock` در محیط‌های non-dev/non-test در `Ai/config/settings.py` با startup check محدود شده است. +- وابستگی مستقیم `Backend/irrigation/services.py` به `mock_data` حذف شده است. +- naming قبلی مبتنی بر `mock_data` در بعضی سرویس‌ها مانند dashboard و irrigation تا حدی تمیز شده است. + +--- + +## 1) ناهماهنگی بین route واقعی، docs و contract + +این هنوز یکی از اصلی‌ترین ضعف‌های پروژه است. + +### نشانه‌ها + +- `POST /api/farm-alerts/timeline/` هنوز `missing` است و route واقعی ندارد. +- endpointهای status برای پیشنهاد آبیاری و کوددهی هنوز `contract_only` هستند: + - `GET /api/irrigation/recommend/{task_id}/status/` + - `GET /api/fertilization/recommend/{task_id}/status/` +- بخشی از routeهای irrigation در backend هنوز با routeهای واقعی AI کاملاً reconcile نشده‌اند. +- بعضی مسیرهای crop simulation روی AI واقعی هستند ولی canonical backend هنوز زیر `yield-harvest/*` تعریف می‌شود. + +### ریسک + +- فرانت یا تیم‌های دیگر ممکن است روی endpointهایی حساب کنند که public-ready نیستند. +- onboarding توسعه‌دهنده جدید سخت می‌شود. +- اختلاف بین semantics واقعی و docs باعث خطای integration می‌شود. + +### شواهد + +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:40` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:41` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:66` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:68` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:72` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:74` +- `Ai/API_RELIABILITY_AUDIT_FA.md:50` + +### اقدام پیشنهادی + +- برای هر endpoint فقط یکی از این وضعیت‌ها نهایی شود: + - `implemented` + - `deprecated` + - `contract-only` + - `missing` +- docs داخلی و frontend-facing از هم تفکیک شوند. +- مسیرهای AI internal و backend public با برچسب ownership مشخص شوند. + +--- + +## 2) باقی‌ماندن `silent fallback` و empty-shape handling + +با اینکه بخشی از error handling بهتر شده، این ضعف هنوز حل نشده است. + +### نقاط مهم + +- در `Backend/irrigation/services.py` هنوز helperهایی وجود دارند که در حالت invalid/empty با `{}` یا `[]` برمی‌گردند. +- در برخی سرویس‌های backend و AI هنوز failure به‌صورت شفاف surface نمی‌شود. +- این الگو مخصوصاً برای debugging و observability مشکل‌ساز است. + +### شواهد + +- `Backend/irrigation/services.py:126` +- `Backend/irrigation/services.py:138` +- `Backend/irrigation/services.py:150` +- `Backend/irrigation/services.py:173` +- `Backend/irrigation/services.py:189` +- `Backend/irrigation/services.py:208` +- `Backend/yield_harvest/views.py:137` +- `Ai/rag/embedding.py:34` + +### ریسک + +- سیستم ظاهراً پاسخ موفق می‌دهد ولی داده ناقص یا تحریف‌شده است. +- root cause واقعی پشت empty response پنهان می‌شود. +- قرارداد خروجی API برای empty-state و error-state یکدست نیست. + +### اقدام پیشنهادی + +- empty-state مجاز فقط برای caseهای مستند و قابل‌تشخیص باشد. +- برای fallbackها `warning`, `reason`, `source`, `status` یا failure contract صریح برگردد. +- broad exception handling محدود و log-aware شود. + +--- + +## 3) حضور mock adapterها در runtime code + +گرچه provider پیش‌فرض دیگر mock نیست، اما حضور mock در runtime code هنوز یک ضعف معماری است. + +### نقاط مهم + +- `Ai/location_data/soil_adapters.py` هنوز `MockSoilDataAdapter` دارد. +- `Ai/weather/adapters.py` هنوز `MockWeatherAdapter` دارد. +- هر دو adapter رفتار synthetic و حتی delay مصنوعی دارند. + +### شواهد + +- `Ai/location_data/soil_adapters.py:102` +- `Ai/location_data/soil_adapters.py:107` +- `Ai/weather/adapters.py:76` +- `Ai/weather/adapters.py:77` +- `Ai/weather/adapters.py:275` + +### ریسک + +- realistic mock می‌تواند با داده واقعی اشتباه گرفته شود. +- اگر تنظیمات محیط اشتباه شود، نتیجه تست‌مانند به‌جای رفتار production دیده می‌شود. +- اعتماد کاذب در validation دستی ایجاد می‌کند. + +### اقدام پیشنهادی + +- mock adapterها فقط در `dev/test` load شوند. +- همه responseهای mock برچسب اجباری `source=mock` و `is_synthetic=true` داشته باشند. +- delay مصنوعی از runtime عادی حذف و فقط در test fixture نگه داشته شود. + +--- + +## 4) debt معماری در `Backend/device_hub/services.py` + +این فایل هنوز یکی از سنگین‌ترین نقاط technical debt است. + +### مسئله + +فایل همزمان چند مسئولیت را انجام می‌دهد: + +- ingestion +- farm-data forwarding +- chart building +- anomaly payload shaping +- history formatting + +همچنین هنوز fallbackهای empty و الگوهای نرمِ failure در آن دیده می‌شود. + +### شواهد + +- `Backend/device_hub/services.py:13` +- `Backend/device_hub/services.py:20` +- `Backend/device_hub/services.py:64` +- `Backend/device_hub/services.py:152` +- `Backend/device_hub/services.py:181` +- `Backend/device_hub/services.py:636` +- `Backend/device_hub/services.py:685` +- `Backend/device_hub/services.py:725` + +### ریسک + +- تغییرات کوچک، regressionهای پیش‌بینی‌نشده ایجاد می‌کند. +- تست‌پذیری و نگهداری فایل پایین است. +- ownership دقیق data flow سخت فهمیده می‌شود. + +### اقدام پیشنهادی + +- split به ماژول‌های جدا برای ingestion، forwarding، analytics و formatters +- حذف mock-oriented chart shaping از مسیر runtime +- تعریف service boundary روشن برای sensor processing + +--- + +## 5) debt معماری در `Backend/farm_alerts/services.py` و semantics مبهم tracker + +### مسئله + +- `farm-alerts/tracker` در backend فعلاً snapshot/cached semantics دارد، نه live AI request-time. +- فایل service همزمان context build، persistence، snapshot update و notification save را انجام می‌دهد. + +### شواهد + +- `Backend/farm_alerts/views.py:57` +- `Backend/farm_alerts/services.py:13` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:40` + +### ریسک + +- مصرف‌کننده API ممکن است این endpoint را live AI تصور کند. +- مرز بین cache، snapshot و inference شفاف نیست. +- نگهداری و تغییر behavior آینده پرریسک می‌شود. + +### اقدام پیشنهادی + +- semantics endpoint در docs و response metadata صریح شود. +- service به ماژول‌های کوچک‌تر مثل `tracker_context`, `tracker_sync`, `snapshots`, `notifications` شکسته شود. + +--- + +## 6) ضعف‌های باقی‌مانده در access control + +### مسئله + +- `Backend/access_control/services.py` هنوز بزرگ و چندمنظوره است. +- بخشی از exceptionها swallowed می‌شوند. +- parsing داده درخواست در همان لایه authorization پیچیده شده است. +- اگر dependencyهایی مثل OPA ناپایدار باشند، degradation policy هنوز کاملاً شفاف نیست. + +### شواهد + +- `Backend/access_control/services.py:321` +- `Backend/access_control/services.py:332` +- `Backend/access_control/services.py:343` + +### ریسک + +- رفتار authorization در failure modeها غیرقابل پیش‌بینی می‌شود. +- fail-open / fail-closed policy ممکن است ناهمگون شود. + +### اقدام پیشنهادی + +- policy هر endpoint برای fail-open یا fail-closed مستند و enforce شود. +- cache exceptionها با logging و classification مناسب مدیریت شوند. +- request parsing از authorization logic جدا شود. + +--- + +## 7) ضعف‌های باقی‌مانده در `Backend/plants` + +این بخش نسبت به قبل بهتر شده، اما هنوز canonical backend CRUD کاملاً نهایی نیست. + +### مسئله + +- backend plant catalog هنوز از نظر تمام operationهای canonical کاملاً تثبیت نشده است. +- بخشی از تفاوت AI route و backend public route هنوز باقی است. +- مسیر incremental sync/change-feed یا webhook/task-based sync هنوز به‌صورت روشن نهایی نشده است. + +### شواهد + +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:58` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:59` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:60` +- `Backend/plants/services.py:94` + +### ریسک + +- source-of-truth بین catalog backend و مصرف AI ممکن است drift کند. +- عملیات مدیریتی catalog به‌صورت کامل و یکنواخت قابل اتکا نیست. + +### اقدام پیشنهادی + +- CRUD canonical backend کامل و صریح شود. +- مکانیزم sync از backend به AI از push ساده به مدل change-feed یا task-based نزدیک شود. + +--- + +## 8) باقی‌ماندن contract/mock catalog در `Backend/external_api_adapter/json/ai/index.json` + +### مسئله + +این فایل هنوز شامل endpointهایی است که route واقعی production نیستند یا فقط `contract-only` هستند. + +### شواهد + +- `Backend/external_api_adapter/json/ai/index.json` +- `Backend/AI_ROUTE_CONNECTION_AUDIT.md:94` +- `Ai/API_RELIABILITY_AUDIT_FA.md:57` + +### ریسک + +- این فایل ممکن است به‌اشتباه به‌عنوان source-of-truth production خوانده شود. +- mock/spec با implementation واقعی قاطی می‌شود. + +### اقدام پیشنهادی + +- endpointهای mock/spec از routeهای واقعی جدا برچسب‌گذاری شوند. +- این فایل صریحاً به‌عنوان `contract/mock catalog` حفظ شود، نه production route registry. + +--- + +## 9) migration ناتمام در `Ai/farm_data` + +### مسئله + +معماری جدید `farm_data` جهت درستی دارد، اما migration آن کامل نشده است. + +### نقاط باقی‌مانده + +- relation قدیمی `SensorData.plants` هنوز transitional است. +- همه consumerها هنوز کامل migrate نشده‌اند. +- strategy نهایی برای sync conflict و reconciliation کامل نشده است. + +### شواهد + +- `Ai/API_RELIABILITY_AUDIT_FA.md:64` +- `Ai/farm_data/models.py:111` + +### ریسک + +- دو source-of-truth موقت هم‌زمان فعال می‌مانند. +- ناسازگاری assignment گیاه، sensor و farm ممکن است رخ دهد. + +### اقدام پیشنهادی + +- relation قدیمی بعد از migration کامل consumerها حذف شود. +- backfill و reconciliation task رسمی تعریف شود. +- source-of-truth نهایی در docs و schemaها تثبیت شود. + +--- + +## 10) ضعف reliability در `Ai/rag/embedding.py` + +### مسئله + +- مدیریت خطای provider call هنوز حداقلی است. +- retry/backoff/circuit-breaker مشخص ندارد. + +### شواهد + +- `Ai/rag/embedding.py:10` +- `Ai/rag/embedding.py:34` + +### ریسک + +- failureهای موقت provider می‌توانند تجربه ناپایدار ایجاد کنند. +- خطاها خوب classify نمی‌شوند. + +### اقدام پیشنهادی + +- retry محدود با backoff +- تفکیک خطاهای موقت و دائمی +- failure contract شفاف برای dependency بیرونی + +--- + +## 11) تست‌ها هنوز در چند بخش بیشتر proxy/contract محور هستند + +### مسئله + +بخشی از تست‌ها route proxy شدن به AI یا mock call شدن dependency را چک می‌کنند، نه لزوماً behavior واقعی production و edge-caseهای داده. + +### نشانه‌ها + +- در `Backend/yield_harvest/tests.py` تمرکز زیادی روی proxy contract دیده می‌شود. +- در چند بخش integration واقعی کمتر از contract mocking پوشش داده شده است. + +### ریسک + +- drift بین قرارداد تست‌شده و رفتار واقعی runtime کشف نمی‌شود. +- regressionهای مربوط به payload semantics، empty-state و partial failure دیر دیده می‌شوند. + +### اقدام پیشنهادی + +- تست‌های behavior-oriented برای failure contract، empty-state و payload validation اضافه شود. +- برای flowهای critical، integration test سبک و کنترل‌شده بیشتر شود. + +--- + +## اولویت‌بندی اصلاح + +### P0 + +- reconcile کامل route/doc/spec +- حذف یا شفاف‌سازی `contract-only` endpointها +- حذف `silent fallback`های باقی‌مانده در flowهای حساس + +### P1 + +- split فایل‌های چندمسئولیتی مانند `device_hub/services.py` و `farm_alerts/services.py` +- محدودکردن runtime mock adapterها به dev/test +- تثبیت ownership و migration در `farm_data` + +### P2 + +- بهبود reliability برای embedding/provider callها +- تقویت تست‌های behavior-oriented +- پاکسازی بیشتر docs قدیمی و واژه‌های مبهم مثل live/proxy/cached + +--- + +## جمع‌بندی نهایی + +پروژه نسبت به auditهای قبلی **در حذف بخشی از وابستگی‌های مستقیم به mock و بهبود provider defaults پیشرفت داشته است**، اما هنوز این ضعف‌های حل‌نشده باقی مانده‌اند: + +- ناهماهنگی contract و route واقعی +- endpointهای `missing` یا `contract-only` +- fallbackهای silent +- حضور mock adapter در runtime code +- technical debt در چند service بزرگ +- migration ناتمام در `farm_data` +- ضعف در behavior-driven validation + +بنابراین وضعیت فعلی پروژه را می‌توان این‌طور جمع‌بندی کرد: + +**پایه معماری نسبت به قبل بهتر شده، اما هنوز production-readiness کامل در همه flowها به‌صورت یکنواخت حاصل نشده است.** diff --git a/SENSOR_ARCHITECTURE_RECOMMENDATION.md b/SENSOR_ARCHITECTURE_RECOMMENDATION.md new file mode 100644 index 0000000..58bc9a0 --- /dev/null +++ b/SENSOR_ARCHITECTURE_RECOMMENDATION.md @@ -0,0 +1,499 @@ +# پیشنهاد معماری سنسور برای Backend و AI + +## مسئله اصلی + +تو سناریوی شما ممکن است: + +- یک مزرعه چندین سنسور داشته باشد +- سنسورها از vendorهای مختلف باشند +- هر سنسور payload متفاوتی بفرستد +- بعضی سنسورها یک metric مشترک مثل `soil_moisture` را هم‌زمان گزارش کنند +- AI مجبور باشد هم raw data را بفهمد و هم یک view تجمیع‌شده برای recommendation داشته باشد + +پس معماری سنسور باید این سه ویژگی را هم‌زمان داشته باشد: + +1. **انعطاف‌پذیری در نوع سنسور** +2. **قابلیت نگهداری چند سنسور برای یک farm** +3. **قابلیت تبدیل raw sensor data به metricهای استاندارد برای AI** + +--- + +## چیزی که الان در پروژه دارید + +### در Backend + +- `Backend/farm_hub/models.py` مدل `FarmSensor` را دارد برای register کردن سنسورهای هر مزرعه +- `Backend/sensor_catalog/models.py` مدل `SensorCatalog` را دارد برای تعریف نوع/کاتالوگ سنسور +- `Backend/sensor_external_api/models.py` لاگ raw requestهای دستگاه‌ها را نگه می‌دارد + +یعنی Backend الان تا حد خوبی نقش **device registry + ingestion gateway + audit log** را دارد. + +### در AI + +- `Ai/farm_data/models.py::SensorData` داده سنسورها را در `sensor_payload` نگه می‌دارد +- `Ai/farm_data/models.py::SensorParameter` پارامترهای قابل پشتیبانی هر `sensor_key` را نگه می‌دارد +- `Ai/farm_data/services.py` روی payloadهای چند سنسوره metric resolve می‌کند + +یعنی AI الان نقش **farm context store + normalized sensor context** را دارد. + +--- + +## جمع‌بندی نقش‌ها + +به نظر من بهترین تفکیک این است: + +### Backend = owner دستگاه و جریان ورود داده + +Backend باید مسئول این‌ها باشد: + +- ثبت سنسور برای مزرعه +- نگهداری metadata دستگاه +- احراز هویت request دستگاه +- ثبت raw payload +- forward کردن داده به AI + +### AI = owner داده تحلیلی و context مزرعه + +AI باید مسئول این‌ها باشد: + +- نگهداری normalized view از داده سنسورها برای هر farm +- استانداردسازی metricها +- merge / aggregation / conflict resolution +- feed کردن irrigation / fertilization / crop simulation / alerts / RAG + +--- + +## پیشنهاد معماری نهایی + +## 1) در Backend فقط registry و ingestion را canonical نگه دار + +### `SensorCatalog` +این جدول باید تعریف‌کننده template سنسور باشد: + +- `code` +- `name` +- `description` +- `returned_data_fields` +- `sample_payload` +- `customizable_fields` +- `supported_power_sources` + +ولی بهتر است بعداً این metadataها را قوی‌تر کنی: + +- `measurement_schema` +- `supported_metrics` +- `payload_mapping` +- `transport_type` مثل `http`, `mqtt`, `lorawan` +- `vendor` +- `model` +- `firmware_constraints` + +### `FarmSensor` +این جدول باید instance واقعی دستگاه روی مزرعه باشد. + +پیشنهاد فیلدهای مفهومی: + +- `farm` +- `sensor_catalog` +- `physical_device_uuid` +- `name` +- `sensor_type` +- `installation_zone` +- `depth_cm` +- `position` +- `status` +- `last_seen_at` +- `calibration` +- `specifications` +- `power_source` +- `metadata` + +یعنی `FarmSensor` باید بگوید: + +- این دستگاه چیست +- کجا نصب شده +- چه زمانی آخرین بار data فرستاده +- به چه farm تعلق دارد + +--- + +## 2) در Backend raw event را جدا از registry نگه دار + +الان `SensorExternalRequestLog` فقط لاگ request است. این خوب است، ولی برای مقیاس‌پذیری بهتر است دو لایه داشته باشی: + +### لایه اول: audit log + +همان چیزی که الان داری: + +- raw payload +- request time +- physical device uuid +- farm uuid + +### لایه دوم: normalized ingestion event + +اگر بخواهی معماری تمیزتر شود، بهتر است یک مدل جدا هم داشته باشی، مثلاً: + +- `SensorReadingEvent` + +که در آن این‌ها ذخیره شود: + +- `farm_sensor` +- `recorded_at` +- `received_at` +- `payload_raw` +- `payload_normalized` +- `ingestion_status` +- `validation_errors` + +این مدل لازم نیست الان فوراً ساخته شود، ولی اگر سنسورها زیاد شوند خیلی کمک می‌کند. + +--- + +## 3) در AI داده سنسورها را به شکل sensor-centric نگه دار، نه فقط metric-centric + +الان `Ai/farm_data/models.py::SensorData` این ساختار را دارد: + +```json +{ + "sensor-7-1": { + "soil_moisture": 22.4, + "soil_temperature": 18.1 + }, + "leaf-sensor": { + "leaf_wetness": 11 + } +} +``` + +این از نظر انعطاف‌پذیری خوب است، ولی یک ضعف دارد: + +- `sensor_key` بیشتر نوع سنسور را نشان می‌دهد، نه instance سنسور + +اگر یک farm دو سنسور از یک type داشته باشد، این ساختار collision می‌دهد. + +### پیشنهاد بهتر + +در AI payload را بر اساس **sensor instance** نگه دار، نه فقط type: + +```json +{ + "device:8c1e...": { + "sensor_key": "sensor-7-1", + "sensor_type": "soil_probe", + "recorded_at": "2026-05-02T10:15:00Z", + "metrics": { + "soil_moisture": 22.4, + "soil_temperature": 18.1 + }, + "metadata": { + "depth_cm": 20, + "zone": "north-1" + } + }, + "device:91af...": { + "sensor_key": "sensor-7-1", + "sensor_type": "soil_probe", + "recorded_at": "2026-05-02T10:15:30Z", + "metrics": { + "soil_moisture": 24.1, + "soil_temperature": 17.9 + }, + "metadata": { + "depth_cm": 40, + "zone": "north-1" + } + } +} +``` + +یعنی key اصلی بهتر است یکی از این‌ها باشد: + +- `physical_device_uuid` +- یا `farm_sensor.uuid` + +نه فقط `sensor-7-1`. + +--- + +## 4) در AI یک لایه normalized metrics جدا بساز + +AI برای recommendation نباید هر بار raw payload را از صفر تفسیر کند. + +بهترین مدل ذهنی این است: + +### لایه A: raw sensor context +- هر sensor instance چه چیزی فرستاده؟ + +### لایه B: resolved farm metrics +- برای farm در این لحظه `soil_moisture` نهایی چقدر است؟ +- source این metric کدام sensorها بوده؟ +- strategy حل conflict چه بوده؟ + +الان `Ai/farm_data/services.py` بخشی از این کار را انجام می‌دهد. این مسیر درست است. + +پیشنهاد من: + +- `sensor_payload` = raw/near-raw normalized by device +- `resolved_metrics` = خروجی استاندارد شده برای AI +- `metric_sources` = توضیح اینکه هر metric از کجا آمده + +این‌ها لازم نیست حتماً همگی DB column جدا باشند؛ فعلاً می‌توانند در service layer ساخته شوند. ولی از نظر معماری باید explicit باشند. + +--- + +## 5) resolution strategy باید قابل تنظیم باشد + +وقتی چند سنسور یک metric مشترک دارند، AI باید بداند چطور resolve کند. + +مثلاً برای `soil_moisture`: + +- اگر چند سنسور هم‌عمق و هم‌زون باشند → average +- اگر depth فرق دارد → shallow و deep را جدا نگه دار +- اگر یکی unhealthy باشد → ignore +- اگر یکی stale باشد → وزن کمتر بگیرد یا حذف شود + +### بنابراین برای هر metric این چیزها مهم می‌شوند: + +- `recorded_at` +- `depth_cm` +- `zone` +- `sensor_health` +- `priority` +- `confidence` + +الان average ساده خوب است برای شروع، ولی برای طراحی نهایی کافی نیست. + +--- + +## 6) schema mapping را از business logic جدا کن + +Backend و AI نباید به aliasهای vendor-specific وابسته بمانند. + +مثلاً: + +- `moisture_percent` +- `soilMoisture` +- `moisture` +- `soil_moisture` + +همه باید به یک metric استاندارد map شوند: + +- `soil_moisture` + +### بهترین محل این mapping + +به نظر من mapping باید در یک لایه canonical تعریف شود: + +- در `SensorCatalog` +- یا در AI داخل `SensorParameter.metadata` + +مثلاً: + +```json +{ + "code": "soil_moisture", + "metadata": { + "aliases": ["moisture_percent", "soilMoisture", "moisture"], + "unit": "%", + "valid_range": [0, 100] + } +} +``` + +--- + +## پیشنهاد عملی برای Backend + +## Backend چه چیزی نگه دارد؟ + +### مدل‌های اصلی + +- `SensorCatalog` = تعریف نوع سنسور +- `FarmSensor` = دستگاه نصب‌شده روی مزرعه +- `SensorExternalRequestLog` = raw ingress log + +### مسئولیت‌ها + +- ثبت sensor inventory +- validate کردن physical device +- ثبت raw payload برای audit +- attach کردن payload به farm درست +- forward کردن payload به AI + +### چیزی که Backend نباید owner آن باشد + +- منطق aggregation نهایی برای recommendation +- conflict resolution تخصصی برای سنسورهای متعدد +- semantic interpretation نهایی برای AI outputs + +--- + +## پیشنهاد عملی برای AI + +## AI چه چیزی نگه دارد؟ + +### `SensorData` +برای هر `farm_uuid`: + +- `sensor_payload` بر اساس device instance +- `plants` +- `irrigation_method` +- `center_location` +- `weather_forecast` + +### `SensorParameter` +برای تعریف metricهای استاندارد: + +- `sensor_key` +- `code` +- `name_fa` +- `unit` +- `data_type` +- `metadata` + +ولی پیشنهاد می‌کنم `metadata` را برای این موارد غنی‌تر کنی: + +- `aliases` +- `valid_range` +- `aggregation_strategy` +- `semantic_group` +- `recommended_for` + +--- + +## ساختار پیشنهادی payload بین Backend و AI + +به‌جای فرستادن فقط این: + +```json +{ + "sensor-7-1": { + "soil_moisture": 45.2 + } +} +``` + +بهتر است به این سمت بروی: + +```json +{ + "device:22222222-2222-2222-2222-222222222222": { + "sensor_key": "sensor-7-1", + "sensor_type": "soil_probe", + "recorded_at": "2026-05-02T10:15:00Z", + "metrics": { + "soil_moisture": 45.2, + "soil_temperature": 22.5 + }, + "metadata": { + "depth_cm": 20, + "zone": "zone-a" + } + } +} +``` + +اگر فعلاً نمی‌خواهی API را بشکنی، حداقل این migration path را برو: + +1. فعلاً `sensor_payload` فعلی را نگه دار +2. اجازه بده علاوه بر `sensor_key`، `physical_device_uuid` هم به AI برسد +3. در AI key داخلی را با device uuid بساز +4. بعداً schema را کامل migrate کن + +--- + +## الگوی تصمیم‌گیری برای چند سنسور + +برای AI پیشنهاد می‌کنم سه خروجی داشته باشی: + +### 1) `raw_sensor_payload` +همه داده‌های هر device بدون از دست رفتن context + +### 2) `resolved_metrics` +مثلاً: + +```json +{ + "soil_moisture": 23.2, + "soil_temperature": 18.5 +} +``` + +### 3) `metric_sources` +مثلاً: + +```json +{ + "soil_moisture": { + "strategy": "weighted_average", + "sensor_keys": ["device:a", "device:b"], + "depths": [20, 40], + "conflict": true + } +} +``` + +این ساختار برای explainability خیلی مهم است. + +--- + +## چیزی که من پیشنهاد می‌کنم همین الان تغییر بدهی + +### تغییرات سریع و پرارزش + +#### در Backend + +- به `FarmSensor` این فیلدها را اضافه کن: + - `metadata` + - `installation_zone` + - `depth_cm` + - `last_seen_at` + - `status` + +#### در AI + +- `sensor_payload` را به‌سمت instance-based key ببر +- `SensorParameter.metadata` را برای alias و aggregation strategy غنی کن +- resolver فعلی را از `average ساده` به strategy-based resolver ارتقا بده + +#### در API بین Backend و AI + +- همراه payload این اطلاعات را هم بفرست: + - `physical_device_uuid` + - `sensor_catalog_uuid` + - `sensor_type` + - `recorded_at` + - `depth_cm` یا `zone` + +--- + +## پیشنهاد naming + +برای شفافیت بیشتر: + +- `SensorCatalog` = نوع سنسور +- `FarmSensor` = سنسور نصب‌شده +- `SensorExternalRequestLog` = raw ingest log +- `SensorData` = farm sensor context + +اگر بعداً مدل event اضافه کردی: + +- `SensorReadingEvent` = reading normalized per device + +--- + +## تصمیم نهایی من + +اگر بخواهم خیلی خلاصه بگویم: + +- **Backend** باید registry, ingestion, audit را handle کند +- **AI** باید normalization, aggregation, context-building, recommendation input را handle کند +- key اصلی داده سنسور باید **sensor instance** باشد، نه فقط `sensor_key` +- raw sensor data و resolved farm metrics باید از هم جدا باشند + +--- + +## نتیجه یک‌خطی + +برای سنسورهای انعطاف‌پذیر و چندتایی، Backend را به‌عنوان لایه ثبت دستگاه و ورود raw data نگه دار و AI را به‌عنوان لایه استانداردسازی و تجمیع metricها؛ و مهم‌تر از همه، داده‌ها را بر اساس **device instance** مدل کن نه فقط نوع سنسور. diff --git a/SensorHub b/SensorHub new file mode 160000 index 0000000..c140caf --- /dev/null +++ b/SensorHub @@ -0,0 +1 @@ +Subproject commit c140caf8b2f2b8228273594bd7ba4ad495d4bed6 diff --git a/Tests/config/apis.yaml b/Tests/config/apis.yaml index 5ade919..959b482 100644 --- a/Tests/config/apis.yaml +++ b/Tests/config/apis.yaml @@ -1,30 +1,16 @@ -base_url: http://backend:8000/api/auth +base_url: http://backend-web:8000/api flows: - register_login: + auth: - register: - method: POST - path: /register/ - body: - username: "{random_username}" - email: "{random_username}@example.com" - phone_number: "09120000000" - password: "test123456" - first_name: "test" - last_name: "user" - - expected_status: 201 - expected_json: - msg: success login: method: POST - path: /login/ + path: /auth/login/ body: - identifier: "{random_username}" - password: "test123456" + identifier: "admin" + password: "admin123456" expected_status: 200 @@ -32,6 +18,6 @@ flows: token: token store_redis: - key: "test_token:{random_username}" + key: "test_token" ttl: 3600 diff --git a/Tests/logs/test.log b/Tests/logs/test.log index c40c6f5..76c0e6b 100644 --- a/Tests/logs/test.log +++ b/Tests/logs/test.log @@ -3,34 +3,8 @@ platform linux -- Python 3.10.20, pytest-9.0.3, pluggy-1.6.0 -- /usr/local/bin/p cachedir: .pytest_cache rootdir: /app configfile: pytest.ini -collecting ... collected 0 items / 1 error +collecting ... collected 1 item -==================================== ERRORS ==================================== -________________ ERROR collecting tests/test_authentication.py _________________ -ImportError while importing test module '/app/tests/test_authentication.py'. -Hint: make sure your test modules/packages have valid Python names. -Traceback: -/usr/local/lib/python3.10/site-packages/_pytest/python.py:507: in importtestmodule - mod = import_path( -/usr/local/lib/python3.10/site-packages/_pytest/pathlib.py:587: in import_path - importlib.import_module(module_name) -/usr/local/lib/python3.10/importlib/__init__.py:126: in import_module - return _bootstrap._gcd_import(name[level:], package, level) -:1050: in _gcd_import - ??? -:1027: in _find_and_load - ??? -:1006: in _find_and_load_unlocked - ??? -:688: in _load_unlocked - ??? -/usr/local/lib/python3.10/site-packages/_pytest/assertion/rewrite.py:197: in exec_module - exec(co, module.__dict__) -tests/test_authentication.py:5: in - from utils.yaml_loader import load_config -E ImportError: cannot import name 'load_config' from 'utils.yaml_loader' (/app/utils/yaml_loader.py) -=========================== short test summary info ============================ -ERROR tests/test_authentication.py -!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!! -!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! -=============================== 1 error in 0.52s =============================== +tests/test_authentication.py::test_auth_flow PASSED [100%] + +============================== 1 passed in 0.67s =============================== diff --git a/Tests/tests/test_authentication.py b/Tests/tests/test_authentication.py index a903e61..05d4d84 100644 --- a/Tests/tests/test_authentication.py +++ b/Tests/tests/test_authentication.py @@ -9,7 +9,7 @@ from utils.template import render config = load_config() BASE_URL = config["base_url"] -flow = config["flows"]["register_login"] +flow = config["flows"]["auth"] redis_client = redis.Redis( host="redis", @@ -22,26 +22,9 @@ def test_auth_flow(): context = {} - context["random_username"] = f"user_{uuid.uuid4().hex[:6]}" # -------- register -------- - register = flow["register"] - - body = render(register["body"], context) - - res = http_request( - register["method"], - BASE_URL + register["path"], - json=body - ) - - assert res["status_code"] == register["expected_status"] - - assert res["json"]["msg"] == register["expected_json"]["msg"] - - # -------- login -------- - login = flow["login"] body = render(login["body"], context) @@ -49,14 +32,16 @@ def test_auth_flow(): res = http_request( login["method"], BASE_URL + login["path"], + authenticated=False, json=body ) + print(res) - assert res["status_code"] == login["expected_status"] + assert res["status"] == login["expected_status"] token_field = login["extract"]["token"] - token = res["json"][token_field] + token = res["data"][token_field] assert token is not None diff --git a/Tests/utils/http_client.py b/Tests/utils/http_client.py index b93b5f5..dc4a890 100644 --- a/Tests/utils/http_client.py +++ b/Tests/utils/http_client.py @@ -1,16 +1,41 @@ import requests import time +import redis +from utils.yaml_loader import load_config -def http_request(method, url, **kwargs): +redis_client = redis.Redis( + host="redis", + port=6379, + decode_responses=True +) + +config = load_config() + + +def http_request(method, url, authenticated=True, **kwargs): start = time.time() - response = requests.request(method, url, **kwargs) + headers = kwargs.pop("headers", {}) + + if authenticated: + token_key = config["flows"]["auth"]["login"]["store_redis"]["key"] + token = redis_client.get(token_key) + + if token: + headers["Authorization"] = f"Bearer {token}" + + response = requests.request( + method, + url, + headers=headers, + **kwargs + ) latency = time.time() - start try: data = response.json() - except: + except Exception: data = response.text return { @@ -18,4 +43,3 @@ def http_request(method, url, **kwargs): "data": data, "latency": latency } - diff --git a/Tests/utils/yaml_loader.py b/Tests/utils/yaml_loader.py index 6bd969c..267d307 100644 --- a/Tests/utils/yaml_loader.py +++ b/Tests/utils/yaml_loader.py @@ -1,6 +1,6 @@ import yaml -def load_apis(): - with open("apis.yaml", "r") as f: +def load_config(): + with open("config/apis.yaml", "r") as f: return yaml.safe_load(f)