diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..46b4ab8 --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +exclude = */docs/*,*/.tox/*,*/.venv/*,*/.pycharm_helpers/*,*/migrations/*,docs/*,fabfile.py,*/__init__.py,django_project/core/settings/*.py +max-line-length = 79 + +# E12x continuation line indentation +# E251 no spaces around keyword / parameter equals +# E303 too many blank lines (3) +# E402 module level import not at top of file +ignore = E125,E126,E251,E303,E402,W504,W60,F405 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 5af2842..86ddca6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -75,7 +75,7 @@ jobs: python manage.py collectstatic --noinput --verbosity 0 export DJANGO_SETTINGS_MODULE=core.settings.test && coverage run manage.py test && coverage xml EOF - docker cp cplus_api_dev_django:/home/web/django_project/coverage.xml ../coverage.xml + docker cp cplus-api-dev-django:/home/web/django_project/coverage.xml ../coverage.xml - name: Show Coverage if: ${{ github.event_name == 'pull_request' }} uses: orgoro/coverage@v3 diff --git a/deployment/.template.env b/deployment/.template.env index e5dbae0..9d175a1 100644 --- a/deployment/.template.env +++ b/deployment/.template.env @@ -7,4 +7,5 @@ DATABASE_HOST=db REDIS_HOST=redis REDIS_PASSWORD=redis_password RABBITMQ_HOST=rabbitmq -SENTRY_DSN=sentry_dsn \ No newline at end of file +SENTRY_DSN=sentry_dsn +STORAGE_EMULATOR_HOST=http://gcs:4443 \ No newline at end of file diff --git a/deployment/.template.test.env b/deployment/.template.test.env index 8404bdf..a8f41b7 100644 --- a/deployment/.template.test.env +++ b/deployment/.template.test.env @@ -7,4 +7,5 @@ DATABASE_HOST=db REDIS_HOST=redis REDIS_PASSWORD=redis_password RABBITMQ_HOST=rabbitmq -SENTRY_DSN=sentry_dsn \ No newline at end of file +SENTRY_DSN=sentry_dsn +STORAGE_EMULATOR_HOST=http://gcs:4443 \ No newline at end of file diff --git a/deployment/docker-compose.override.devcontainer.yml b/deployment/docker-compose.override.devcontainer.yml index 02baf13..7547b83 100644 --- a/deployment/docker-compose.override.devcontainer.yml +++ b/deployment/docker-compose.override.devcontainer.yml @@ -38,6 +38,7 @@ services: volumes: - ../:/home/web/project - ../django_project:/home/web/django_project + - ./volumes/media:/home/web/media links: - gcs @@ -58,12 +59,10 @@ services: dockerfile: deployment/docker/Dockerfile target: vscode entrypoint: [] - environment: - - STORAGE_EMULATOR_HOST=http://gcs:4443 volumes: - ../:/home/web/project - - ./volumes/static:/home/web/static - ./volumes/media:/home/web/media + - ./volumes/static:/home/web/static links: - db - worker diff --git a/deployment/docker-compose.override.template.yml b/deployment/docker-compose.override.template.yml index b433690..555c88c 100644 --- a/deployment/docker-compose.override.template.yml +++ b/deployment/docker-compose.override.template.yml @@ -19,32 +19,67 @@ services: volumes: - ../django_project:/home/web/django_project - ./volumes/static:/home/web/static - - ./volumes/media:/home/web/media celery_beat: + image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_worker_dev + build: + context: ../ + dockerfile: deployment/docker/Dockerfile + target: worker volumes: + - ../:/home/web/project - ../django_project:/home/web/django_project worker: + image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_worker_dev + build: + context: ../ + dockerfile: deployment/docker/Dockerfile + target: worker volumes: + - ../:/home/web/project - ../django_project:/home/web/django_project + - ./volumes/media:/home/web/media dev: + image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_dev build: context: ../ dockerfile: deployment/docker/Dockerfile target: dev + entrypoint: [] volumes: - ../django_project:/home/web/django_project - ./volumes/static:/home/web/static - ./volumes/media:/home/web/media + links: + - db + - worker + - gcs nginx: volumes: - ./nginx/sites-enabled:/etc/nginx/conf.d:ro - ./volumes/static:/home/web/static - - ./volumes/media:/home/web/media ports: - "${HTTP_PORT:-8888}:80" links: - django + + gcs: + container_name: googlecloudstorage + image: fsouza/fake-gcs-server:1.47.8 + command: + - '-scheme' + - 'http' + - '-port' + - '4443' + - '-public-host' + - '127.0.0.1:4443' + - '-external-url' + - 'http://127.0.0.1:4443' + volumes: + - ./volumes/gcs_data:/storage + - ./gcs_emulator:/data + ports: + - 4443:4443 diff --git a/deployment/docker-compose.override.test.yml b/deployment/docker-compose.override.test.yml index cd338eb..5f72604 100644 --- a/deployment/docker-compose.override.test.yml +++ b/deployment/docker-compose.override.test.yml @@ -26,11 +26,7 @@ services: - 4443:4443 worker: - image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_worker_dev - build: - context: ../ - dockerfile: deployment/docker/Dockerfile - target: worker + image: kartoza/cplus-api:test volumes: - ../django_project:/home/web/django_project - ./volumes/media:/home/web/media @@ -38,17 +34,12 @@ services: - gcs dev: - image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_dev - build: - context: ../ - dockerfile: deployment/docker/Dockerfile - target: dev + image: kartoza/cplus-api:test entrypoint: [] - environment: - - STORAGE_EMULATOR_HOST=http://gcs:4443 volumes: - ../django_project:/home/web/django_project - ./volumes/static:/home/web/static + - ./volumes/media:/home/web/media links: - db - worker diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index d5088f6..488a07f 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -1,38 +1,41 @@ version: '3.9' -name: 'cplus' volumes: + conf-data: static-data: media-data: - conf-data: database: nginx-cache: backups-data: data-volume: +x-common-variables: &common-variables + # editable in .env + DATABASE_NAME: ${DATABASE_NAME:-django} + DATABASE_USERNAME: ${DATABASE_USERNAME:-docker} + DATABASE_PASSWORD: ${DATABASE_PASSWORD:-docker} + DATABASE_HOST: ${DATABASE_HOST:-db} + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_password} + RABBITMQ_HOST: ${RABBITMQ_HOST:-rabbitmq} + DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-core.settings.prod} + INITIAL_FIXTURES: ${INITIAL_FIXTURES:-False} + CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-[]} + SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} + SENTRY_DSN: ${SENTRY_DSN:-} + # Email where alters should be sent. This will be used by let's encrypt and as the django admin email. + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} + # worker variables + CPLUS_QUEUE_CONCURRENCY: ${CPLUS_QUEUE_CONCURRENCY:-1} + + x-common-django: &default-common-django image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}:${DJANGO_TAG:-1.0.0} environment: - # editable in .env - - DATABASE_NAME=${DATABASE_NAME:-django} - - DATABASE_USERNAME=${DATABASE_USERNAME:-docker} - - DATABASE_PASSWORD=${DATABASE_PASSWORD:-docker} - - DATABASE_HOST=${DATABASE_HOST:-db} - - REDIS_HOST=${REDIS_HOST:-redis} - - REDIS_PASSWORD=${REDIS_PASSWORD:-redis_password} - - RABBITMQ_HOST=${RABBITMQ_HOST:-rabbitmq} - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-core.settings.prod} - - INITIAL_FIXTURES=${INITIAL_FIXTURES:-False} - - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:-[]} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-production} - - SENTRY_DSN=${SENTRY_DSN:-} - # Email where alters should be sent. This will be used by let's encrypt and as the django admin email. - - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - # worker variables - - CPLUS_QUEUE_CONCURRENCY=${CPLUS_QUEUE_CONCURRENCY:-1} + <<: *common-variables restart: on-failure services: @@ -79,7 +82,6 @@ services: command: 'uwsgi --ini /uwsgi.conf' volumes: - static-data:/home/web/static - - media-data:/home/web/media links: - db - redis @@ -99,8 +101,11 @@ services: container_name: "cplus-api-worker" entrypoint: [] command: '/bin/bash -c /home/web/django_project/worker_entrypoint.sh' - links: - - celery_beat + environment: + <<: *common-variables + CPLUS_WORKER: 1 + volumes: + - media-data:/home/web/media dev: image: kartoza/${COMPOSE_PROJECT_NAME:-django_project}_dev @@ -121,8 +126,7 @@ services: hostname: nginx volumes: - conf-data:/etc/nginx/conf.d:ro - - static-data:/home/web/static - - media-data:/home/web/media - nginx-cache:/home/web/nginx_cache + - static-data:/home/web/static links: - django diff --git a/deployment/docker/Dockerfile b/deployment/docker/Dockerfile index 35cc562..1afc822 100644 --- a/deployment/docker/Dockerfile +++ b/deployment/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.0-slim-bookworm AS prod +FROM python:3.11.8-slim-bookworm AS prod RUN apt-get update -y && \ apt-get install -y --no-install-recommends \ @@ -6,7 +6,16 @@ RUN apt-get update -y && \ spatialite-bin libsqlite3-mod-spatialite \ python3-dev python3-gdal python3-psycopg2 python3-ldap \ python3-pip python3-pil python3-lxml python3-pylibmc \ - uwsgi uwsgi-plugin-python3 + uwsgi uwsgi-plugin-python3 wget \ + gnupg software-properties-common + +# qgis python3-qgis qgis-plugin-grass +RUN mkdir -m755 -p /etc/apt/keyrings +RUN wget -O /etc/apt/keyrings/qgis-archive-keyring.gpg https://download.qgis.org/downloads/qgis-archive-keyring.gpg +ADD deployment/docker/qgis.sources /etc/apt/sources.list.d/qgis.sources + +RUN apt-get update -y && \ + apt-get install -y qgis python-qgis qgis-plugin-grass # Install pip packages ADD deployment/docker/requirements.txt /requirements.txt @@ -16,6 +25,9 @@ RUN pip3 install --upgrade pip && pip install --upgrade pip ARG CPUCOUNT=1 RUN pip3 install -r /requirements.txt +# Fix pyqgis missing core lib +# RUN cp /usr/lib/python3/dist-packages/qgis/_core.cpython-311-x86_64-linux-gnu.so /usr/lib/python3/dist-packages/qgis/_core.so + ADD django_project /home/web/django_project EXPOSE 8080 diff --git a/deployment/docker/qgis.sources b/deployment/docker/qgis.sources new file mode 100644 index 0000000..3ece1c6 --- /dev/null +++ b/deployment/docker/qgis.sources @@ -0,0 +1,6 @@ +Types: deb deb-src +URIs: https://qgis.org/debian +Suites: bookworm +Architectures: amd64 +Components: main +Signed-By: /etc/apt/keyrings/qgis-archive-keyring.gpg \ No newline at end of file diff --git a/deployment/docker/requirements.txt b/deployment/docker/requirements.txt index ef5d2e4..035e89f 100644 --- a/deployment/docker/requirements.txt +++ b/deployment/docker/requirements.txt @@ -47,3 +47,6 @@ django-storages[google] # drf yasg drf-yasg==1.21.7 + +# fix pyqgis +PyQt5-sip diff --git a/django_project/core/celery.py b/django_project/core/celery.py index 2d677fc..99d0123 100644 --- a/django_project/core/celery.py +++ b/django_project/core/celery.py @@ -2,6 +2,7 @@ import os import logging +import sys from celery import Celery, signals from celery.utils.serialization import strtobool from celery.worker.control import inspect_command @@ -32,16 +33,20 @@ # celery-is-rerunning-long-running-completed-tasks-over-and-over app.conf.broker_transport_options = {'visibility_timeout': 3 * 3600} +# use max task = 1 to avoid memory leak from qgis processing tools +app.conf.worker_max_tasks_per_child = 1 + # ------------------------------------ # Task event handlers # ------------------------------------ + @signals.after_task_publish.connect def task_sent_handler(sender=None, headers=None, body=None, **kwargs): # task is sent to celery, but might not be queued to worker yet info = headers if 'task' in headers else body - task_id = info['id'] - task_args = info['argsrepr'] if 'argsrepr' in info else '' + # task_id = info['id'] + # task_args = info['argsrepr'] if 'argsrepr' in info else '' if info['task'] in EXCLUDED_TASK_LIST: return @@ -49,8 +54,8 @@ def task_sent_handler(sender=None, headers=None, body=None, **kwargs): @signals.task_received.connect def task_received_handler(sender, request=None, **kwargs): # task should be queued - task_id = request.id if request else None - task_args = request.args + # task_id = request.id if request else None + # task_args = request.args task_name = request.name if request else '' if task_name in EXCLUDED_TASK_LIST: return @@ -70,7 +75,7 @@ def task_success_handler(sender, **kwargs): task_name = sender.name if sender else '' if task_name in EXCLUDED_TASK_LIST: return - task_id = sender.request.id + # task_id = sender.request.id @signals.task_failure.connect @@ -86,7 +91,7 @@ def task_revoked_handler(sender, request = None, **kwargs): task_name = sender.name if sender else '' if task_name in EXCLUDED_TASK_LIST: return - task_id = request.id if request else None + # task_id = request.id if request else None @signals.task_internal_error.connect @@ -122,6 +127,7 @@ def task_retry_handler(sender, reason, **kwargs): # }, # } + @inspect_command( alias='dump_conf', signature='[include_defaults=False]', @@ -134,3 +140,15 @@ def conf(state, with_defaults=False, **kwargs): (Celery makes an attempt to remove sensitive info,but it is not foolproof) """ return {'error': 'Config inspection has been disabled.'} + + +is_worker = os.environ.get('CPLUS_WORKER', 0) +if is_worker: + # init qgis + sys.path.insert(0, '/usr/share/qgis/python/plugins') + sys.path.insert(0, '/usr/share/qgis/python') + sys.path.append('/usr/lib/python3/dist-packages') + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from qgis.core import * # noqa + QgsApplication.setPrefixPath("/usr/bin/qgis", True) + logger.info('*******QGIS INIT DONE*********') diff --git a/django_project/core/models/preferences.py b/django_project/core/models/preferences.py index a14f391..37d4808 100644 --- a/django_project/core/models/preferences.py +++ b/django_project/core/models/preferences.py @@ -1,6 +1,5 @@ """Model for Website Preferences.""" from django.db import models -from django.utils.translation import gettext_lazy as _ from core.models.singleton import SingletonModel diff --git a/django_project/core/urls.py b/django_project/core/urls.py index bca41fd..691aa57 100644 --- a/django_project/core/urls.py +++ b/django_project/core/urls.py @@ -66,7 +66,8 @@ def get_schema(self, request=None, public=False): name='site-preferences' ), path('admin/', admin.site.urls), - re_path(r'^api/v1/', include(('cplus_api.urls_v1', 'api'), namespace='v1')), + re_path(r'^api/v1/', + include(('cplus_api.urls_v1', 'api'), namespace='v1')), ] if settings.DEBUG: diff --git a/django_project/cplus/admin.py b/django_project/cplus/admin.py index 8c38f3f..846f6b4 100644 --- a/django_project/cplus/admin.py +++ b/django_project/cplus/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - # Register your models here. diff --git a/django_project/cplus/data/default/implementation_models.json b/django_project/cplus/data/default/implementation_models.json new file mode 100644 index 0000000..2c7e74c --- /dev/null +++ b/django_project/cplus/data/default/implementation_models.json @@ -0,0 +1,333 @@ +{ + "models": [ + { + "uuid": "a0b8fd2d-1259-4141-9ad6-d4369cf0dfd4", + "name": "Agroforestry", + "description": " Agroforestry is an integrated land use system that combines the cultivation of trees with agricultural crops and/or livestock. It promotes sustainable land management, biodiversity conservation, soil health improvement, and diversified income sources for farmers.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#d80007", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "Reds"} + } + }, + { + "uuid": "1c8db48b-717b-451b-a644-3af1bee984ea", + "name": "Alien Plant Removal", + "description": "This model involves the removal of invasive alien plant species that negatively impact native ecosystems. By eradicating these plants, natural habitats can be restored, allowing native flora and fauna to thrive.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "3c155210-ccd8-404b-bbe8-b1433d6158a2", + "9f6c8b8f-0648-44ca-b943-58fab043f559", + "9291a5d9-d1cd-44c2-8fc3-2b3b20f80572" + ], + "style": { + "scenario_layer": { + "color": "#6f6f6f", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "Greys"} + } + }, + { + "uuid": "de9597b2-f082-4299-9620-1da3bad8ab62", + "name": "Applied Nucleation", + "description": " Applied nucleation is a technique that jump-starts the restoration process by creating focal points of vegetation growth within degraded areas. These 'nuclei' serve as centers for biodiversity recovery, attracting seeds, dispersers, and other ecological processes, ultimately leading to the regeneration of the surrounding landscape.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#81c4ff", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "PuBu"} + } + }, + { + "uuid": "40f04ea6-1f91-4695-830a-7d46f821f5db", + "name": "Assisted Natural Regeneration", + "description": " This model focuses on facilitating the natural regeneration of forests and degraded lands by removing barriers (such as alien plants or hard crusted soils) and providing support for native plant species to regrow. It involves activities such as removing competing vegetation, protecting young seedlings, and restoring ecosystem functions.", + "pwls_ids": [ + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "85cd441e-fa3d-46e4-add9-973ba58f8bd4", + "5e41f4fa-3d7f-41aa-bee7-b9e9d08b56db", + "86c3dfc5-58d7-4ebd-a851-3b65a6bf5edd", + "620d5d7d-c452-498f-b848-b206a76891cd" + ], + "style": { + "scenario_layer": { + "color": "#e8ec18", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "YlOrRd"} + } + }, + { + "uuid": "43f96ed8-cd2f-4b91-b6c8-330d3b93bcc1", + "name": "Avoided Deforestation and Degradation", + "description": " This model focuses on preventing the conversion of forested areas into other land uses and minimizing degradation of existing forests. It involves implementing measures to protect and sustainably manage forests, preserving their biodiversity, carbon sequestration potential, and ecosystem services.", + "pwls_ids": [ + "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#ff4c84", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "RdPu"} + } + }, + { + "uuid": "c3c5a381-2b9f-4ddc-8a77-708239314fb6", + "name": "Avoided Wetland Conversion/Restoration", + "description": " This model aims to prevent the conversion of wetland ecosystems into other land uses and, where possible, restore degraded wetlands. It involves implementing conservation measures, such as land-use planning, regulatory frameworks, and restoration efforts, to safeguard the ecological functions and biodiversity of wetland habitats", + "pwls_ids": [ + "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#1f31d3", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "Blues"} + } + }, + { + "uuid": "3defbd0e-2b12-4ab2-a7d4-a035152396a7", + "name": "Bioproducts", + "description": " The bioproducts model focuses on utilizing natural resources sustainably to create value-added products. It involves the development and production of renewable and biodegradable materials, such as biofuels, bio-based chemicals, and bio-based materials, to reduce reliance on fossil fuels and promote a more sustainable economy.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "fef3c7e4-0cdf-477f-823b-a99da42f931e", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "fb92cac1-7744-4b11-8238-4e1da97650e0", + "9e5cff3f-73e7-4734-b76a-2a9f0536fa27", + "c5b1b81e-e1ae-41ec-adeb-7388f7597156", + "3872be6d-f791-41f7-b031-b85173e41d5e" + ], + "style": { + "scenario_layer": { + "color": "#67593f", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "BrBG"} + } + }, + { + "uuid": "22f9e555-0356-4b18-b292-c2d516dcdba5", + "name": "Bush Thinning", + "description": "Bush thinning refers to the controlled removal of excess woody vegetation in certain ecosystems and using that biomass to brush pack bare soil areas to promote regrowth of grass. This practice helps restore natural balance, prevent overgrowth, and enhance biodiversity.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "fef3c7e4-0cdf-477f-823b-a99da42f931e", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "e1a801c5-7f77-4746-be34-0138b62ff25c", + "478b0729-a507-4729-b1e4-b2bea7e161fd", + "5f329f53-31ff-4039-b0ec-a8d174a50866", + "5bcebbe2-7035-4d81-9817-0b4db8aa63e2" + ], + "style": { + "scenario_layer": { + "color": "#30ff01", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "BuGn"} + } + }, + { + "uuid": "177f1f27-cace-4f3e-9c3c-ef2cf54fc283", + "name": "Direct Tree Seeding", + "description": " This model involves planting tree seeds directly into the ground, allowing them to grow and establish without the need for nursery cultivation. It is a cost-effective and environmentally friendly approach to reforestation and afforestation efforts, promoting forest restoration and carbon sequestration.", + "pwls_ids": [ + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#bd6b70", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "PuRd"} + } + }, + { + "uuid": "d9d00a77-3db1-4390-944e-09b27bcbb981", + "name": "Livestock Rangeland Management", + "description": "This model focuses on sustainable management practices for livestock grazing on rangelands. It includes rotational grazing, monitoring of vegetation health, and implementing grazing strategies that promote biodiversity, soil health, and sustainable land use.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "fee0b421-805b-4bd9-a629-06586a760405", + "38a33633-9198-4b55-a424-135a4d522973", + "88dc8ff3-e61f-4a48-8f9b-5791efb6603f", + "a1bfff8e-fb87-4bca-97fa-a984d9bde712" + ], + "style": { + "scenario_layer": { + "color": "#ffa500", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "YlOrBr"} + } + }, + { + "uuid": "4fbfcb1c-bfd7-4305-b216-7a1077a2ccf7", + "name": "Livestock Market Access", + "description": " This model aims to improve market access for livestock producers practicing sustainable and regenerative farming methods. It involves creating networks, certifications, and partnerships that support the sale of sustainably produced livestock products, promoting economic viability and incentivizing environmentally friendly practices.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#6c0009", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "PRGn"} + } + }, + { + "uuid": "20491092-e665-4ee7-b92f-b0ed864c7312", + "name": "Natural Woodland Livestock Management", + "description": " This model emphasizes the sustainable management of livestock within natural woodland environments. It involves implementing practices that balance livestock grazing with the protection and regeneration of native woodlands, ensuring ecological integrity while meeting livestock production goals.", + "pwls_ids": [ + "c931282f-db2d-4644-9786-6720b3ab206a", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#007018", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "Greens"} + } + }, + { + "uuid": "1334cc3b-cb7b-46a3-923a-45d9b18d9d56", + "name": "Protected Area Expansion", + "description": "This model involves expanding existing protected areas to protect more grassland, savanna, and forest from converting to an anthropogenic land cover class.", + "pwls_ids": [ + "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#c27ba0", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "PiYG"} + } + }, + { + "uuid": "1334cc3b-cb7b-46a3-923a-45d9b18d9d56", + "name": "Protected Area Expansion", + "description": "This model involves expanding existing protected areas to protect more grassland, savanna, and forest from converting to an anthropogenic land cover class.", + "pwls_ids": [ + "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20" + ], + "style": { + "scenario_layer": { + "color": "#c27ba0", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "PiYG"} + } + }, + { + "uuid": "92054916-e8ea-45a0-992c-b6273d1b75a7", + "name": "Sustainable Crop Farming & Aquaponics", + "description": " This model combines sustainable crop farming practices such as agroecology, Permaculture and aquaponics, a system that integrates aquaculture (fish farming) with hydroponics (soil-less crop cultivation). It enables the production of crops with sustainable practices in a mutually beneficial and resource-efficient manner, reducing water usage and chemical inputs while maximizing productivity.", + "pwls_ids": [ + "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "fce41934-5196-45d5-80bd-96423ff0e74e", + "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "2f76304a-bb73-442c-9c02-ff9945389a20", + "6f7c1494-f73e-4e5e-8411-59676f9fa6e1", + "151668e7-8ffb-4766-9534-09949ab0356b", + "ed1ee71b-e7db-4599-97a9-a97c941a615f", + "307df1f4-206b-4f70-8db4-6505948e2a4e" + ], + "style": { + "scenario_layer": { + "color": "#781a8b", + "style": "solid", + "outline_width": "0", + "outline_color": "35,35,35,0" + }, + "model_layer": {"color_ramp": "Purples"} + } + } +] +} diff --git a/django_project/cplus/data/default/ncs_pathways.json b/django_project/cplus/data/default/ncs_pathways.json new file mode 100644 index 0000000..764ee38 --- /dev/null +++ b/django_project/cplus/data/default/ncs_pathways.json @@ -0,0 +1,124 @@ +{ + "pathways": [ + { + "uuid": "b187f92f-b85b-45c4-9179-447f7ea114e3", + "name": "Agroforestry", + "description": "Provides additional carbon sequestration in agricultural systems by strategically planting trees in croplands.", + "path": "Final_Agroforestry_Priority_norm.tif", + "layer_type": 0, + "carbon_paths": ["bou_SOC_carbonsum_norm_inverse_null_con_clip.tif"] + }, + { + "uuid": "5fe775ba-0e80-4b70-a53a-1ed874b72da3", + "name": "Alien Plant Removal", + "description": "Alien Plant Class.", + "path": "Final_Alien_Invasive_Plant_priority_norm.tif", + "layer_type": 0, + "carbon_paths": [] + }, + { + "uuid": "bd381140-64f0-43d0-be6c-50120dd6c174", + "name": "Animal Management", + "description": "Provides additional soil carbon sequestration, reduces methane emissions from ruminants, and improves feed efficiency.", + "path": "Final_Animal_Management_Priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_null_con_clip.tif", + "SOC_trend_30m_4_scaled_clip_norm_inverse_null_con_clip.tif" + ] + }, + { + "uuid": "fc36dd06-aea3-4067-9626-2d73916d79b0", + "name": "Avoided Deforestation", + "description": "Avoids carbon emissions by preventing forest conversion in areas with a high risk of deforestation. Forest is defined as indigenous forest regions with tree density exceeding 75% with a canopy over 6m.", + "path": "Final_Avoided_Indigenous_Forest_priority_norm.tif", + "layer_type": 0, + "carbon_paths": ["bou_SOC_carbonsum_norm_null_con_clip.tif"] + }, + { + "uuid": "f7084946-6617-4c5d-97e8-de21059ca0d2", + "name": "Avoided Grassland Conversion", + "description": "Avoids carbon emissions by preventing the conversion of grasslands in areas with a high risk of grassland loss. Grassland is defined as regions with vegetation density less than 10%.", + "path": "Final_Avoided_Grassland_priority_norm.tif", + "layer_type": 0, + "carbon_paths": ["bou_SOC_carbonsum_norm_null_con_clip.tif"] + }, + { + "uuid": "00db44cf-a2e7-428a-86bb-0afedb9719ec", + "name": "Avoided Savanna Woodland Conversion", + "description": "Avoids carbon emissions by preventing the conversion of open woodland in areas with a high risk of open woodland loss. Savanna woodland is defined as savanna regions with open woodlands (vegetation density less than 35% and a tree canopy greater than 2.5m) and natural wooded lands (vegetation density greater than 35% and a tree canopy between 2.5m and 6m).", + "path": "Final_Avoided_OpenWoodland_NaturalWoodedland_priority_norm.tif", + "layer_type": 0, + "carbon_paths": ["bou_SOC_carbonsum_norm_null_con_clip.tif"] + }, + { + "uuid": "7228ecae-8759-448d-b7ea-19366f74ee02", + "name": "Avoided Wetland Conversion", + "description": "Avoids carbon emissions by preventing the conversion of wetlands in areas with a high risk of wetland loss. Wetlands are defined as natural or semi-natural wetlands covered in permanent or seasonal herbaceous vegetation.", + "path": "Final_Avoided_Wetland_priority_norm.tif", + "layer_type": 0, + "carbon_paths": ["bou_SOC_carbonsum_norm_null_con_clip.tif"] + }, + { + "uuid": "5475dd4a-5efc-4fb4-ae90-68ff4102591e", + "name": "Fire Management", + "description": "Provides additional sequestration and avoids carbon emissions by increasing resilience to catastrophic fire.", + "path": "Final_Fire_Management_Priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_null_con_clip.tif", + "SOC_trend_30m_4_scaled_clip_norm_inverse_null_con_clip.tif" + ] + }, + { + "uuid": "bede344c-9317-4c3f-801c-3117cc76be2c", + "name": "Restoration - Forest", + "description": "Provides additional carbon sequestration by converting non-forest into forest in areas where forests are the native cover type. This pathway excludes afforestation, where native non-forest areas are converted to forest. Forest is defined as indigenous forest regions with tree density exceeding 75% with a canopy over 6m.", + "path": "Final_Forest_Restoration_priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_inverse_null_con_clip.tif", + "SOC_trend_30m_4_scaled_clip_norm_inverse_null_con_clip.tif" + ] + }, + { + "uuid": "384863e3-08d1-453b-ac5f-94ad6a6aa1fd", + "name": "Restoration - Savanna", + "description": "Sequesters carbon through the restoration of native grassland and open woodland habitat. This pathway excludes the opportunity to convert non-native savanna regions to savannas. Savanna in this context contains grasslands (vegetation density less than 10%), open woodlands (vegetation density less than 35% and a tree canopy greater than 2.5m), and natural wooded lands (vegetation density greater than 75% and a tree canopy between 2.5m and 6m).", + "path": "Final_Sananna_Restoration_priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_inverse_null_con_clip.tif", + "SOC_trend_30m_4_scaled_clip_norm_inverse_null_con_clip.tif" + ] + }, + { + "uuid": "540470c7-0ed8-48af-8d91-63c15e6d69d7", + "name": "Restoration - Wetland", + "description": "Sequesters carbon through the restoration of wetland habitat. This pathway excludes the opportunity to convert non-native wetland regions to wetlands. Wetlands are defined as natural or semi-natural wetlands covered in permanent or seasonal herbaceous vegetation.", + "path": "Final_Wetland_Restoration_priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_inverse_null_con_clip.tif", + "SOC_trend_30m_4_scaled_clip_norm_inverse_null_con_clip.tif" + ] + }, + { + "uuid": "e6d7d4cd-dd6b-4ad5-b8a6-eab5436a89f1", + "name": "Sustainable Agriculture Crop Farming", + "description": "Change from natural grassland, woodland, forest in 1990 into cropland.", + "path": "Final_Sustinable_Ag_Crop_Farming_priority_norm.tif", + "layer_type": 0, + "carbon_paths": [ + "bou_SOC_carbonsum_norm_null_con_clip.tif" + ] + }, + { + "uuid": "71de0448-46c4-4163-a124-3d88cdcbba42", + "name": "Woody Encroachment Control", + "description": "Gradual woody plant encroachment into non-forest biomes has important negative consequences for ecosystem functioning, carbon balances, and economies.", + "path": "Final_woody_encroachment_norm.tif", + "layer_type": 0 + } +] +} diff --git a/django_project/cplus/data/default/priority_weighting_layers.json b/django_project/cplus/data/default/priority_weighting_layers.json new file mode 100644 index 0000000..2c739d8 --- /dev/null +++ b/django_project/cplus/data/default/priority_weighting_layers.json @@ -0,0 +1,60 @@ +{ + "layers" :[ + { + "uuid": "c931282f-db2d-4644-9786-6720b3ab206a", + "name": "Social norm", + "description": "Placeholder text for social norm ", + "selected": true, + "path": "social_int_clip_norm.tif" + }, + { + "uuid": "f5687ced-af18-4cfc-9bc3-8006e40420b6", + "name": "Social norm inverse", + "description": "Placeholder text for social norm inverse", + "selected": false, + "path": "social_int_clip_norm_inverse.tif" + }, + { + "uuid": "fef3c7e4-0cdf-477f-823b-a99da42f931e", + "name": "Climate Resilience norm inverse", + "description": "Placeholder text for climate resilience", + "selected": false, + "path": "cccombo_clip_norm_inverse.tif" + }, + { + "uuid": "fce41934-5196-45d5-80bd-96423ff0e74e", + "name": "Climate Resilience norm", + "description": "Placeholder text for climate resilience norm", + "selected": false, + "path": "cccombo_clip_norm.tif" + }, + { + "uuid": "88c1c7dd-c5d1-420c-a71c-a5c595c1c5be", + "name": "Ecological Infrastructure", + "description": "Placeholder text for ecological infrastructure", + "selected": false, + "path": "ei_all_gknp_clip_norm.tif" + }, + { + "uuid": "3e0c7dff-51f2-48c5-a316-15d9ca2407cb", + "name": "Ecological Infrastructure inverse", + "description": "Placeholder text for ecological infrastructure inverse", + "selected": false, + "path": "ei_all_gknp_clip_norm.tif" + }, + { + "uuid": "9ab8c67a-5642-4a09-a777-bd94acfae9d1", + "name": "Biodiversity norm", + "description": "Placeholder text for biodiversity norm", + "selected": false, + "path": "biocombine_clip_norm.tif" + }, + { + "uuid": "c2dddd0f-a430-444a-811c-72b987b5e8ce", + "name": "Biodiversity norm inverse", + "description": "Placeholder text for biodiversity norm inverse", + "selected": false, + "path": "biocombine_clip_norm_inverse.tif" + } + ] +} diff --git a/django_project/cplus/data/layers/null_raster.tif b/django_project/cplus/data/layers/null_raster.tif new file mode 100644 index 0000000..50437df Binary files /dev/null and b/django_project/cplus/data/layers/null_raster.tif differ diff --git a/django_project/cplus/data/reports/main.qpt b/django_project/cplus/data/reports/main.qpt new file mode 100644 index 0000000..24072dc --- /dev/null +++ b/django_project/cplus/data/reports/main.qpt @@ -0,0 +1,2878 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ + + + + + + + + + + + + +
+ + + + + + +
diff --git a/django_project/cplus/definitions/__init__.py b/django_project/cplus/definitions/__init__.py new file mode 100644 index 0000000..833805c --- /dev/null +++ b/django_project/cplus/definitions/__init__.py @@ -0,0 +1,2 @@ +from .constants import * # noqa +from .defaults import * # noqa diff --git a/django_project/cplus/definitions/constants.py b/django_project/cplus/definitions/constants.py new file mode 100644 index 0000000..e163328 --- /dev/null +++ b/django_project/cplus/definitions/constants.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +""" +Definitions for application constants. +""" + + +NCS_PATHWAY_SEGMENT = "ncs_pathways" +NCS_CARBON_SEGMENT = "ncs_carbon" +PRIORITY_LAYERS_SEGMENT = "priority_layers" + +# Naming for outputs sub-folder relative to base directory +OUTPUTS_SEGMENT = "outputs" + +IM_GROUP_LAYER_NAME = "Implementation Model Maps" +IM_WEIGHTED_GROUP_NAME = "Weighted Implementation Model Maps" +NCS_PATHWAYS_GROUP_LAYER_NAME = "NCS Pathways Maps" + +# Attribute names +CARBON_COEFFICIENT_ATTRIBUTE = "carbon_coefficient" +CARBON_PATHS_ATTRIBUTE = "carbon_paths" +COLOR_RAMP_PROPERTIES_ATTRIBUTE = "color_ramp" +COLOR_RAMP_TYPE_ATTRIBUTE = "ramp_type" +DESCRIPTION_ATTRIBUTE = "description" +IM_LAYER_STYLE_ATTRIBUTE = "model_layer" +IM_SCENARIO_STYLE_ATTRIBUTE = "scenario_layer" +LAYER_TYPE_ATTRIBUTE = "layer_type" +NAME_ATTRIBUTE = "name" +PATH_ATTRIBUTE = "path" +PATHWAYS_ATTRIBUTE = "pathways" +PIXEL_VALUE_ATTRIBUTE = "style_pixel_value" +STYLE_ATTRIBUTE = "style" +USER_DEFINED_ATTRIBUTE = "user_defined" +UUID_ATTRIBUTE = "uuid" + +MODEL_IDENTIFIER_PROPERTY = "model_identifier" diff --git a/django_project/cplus/definitions/defaults.py b/django_project/cplus/definitions/defaults.py new file mode 100644 index 0000000..958e3bf --- /dev/null +++ b/django_project/cplus/definitions/defaults.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +""" + Definitions for all defaults settings +""" + +import os +import json + +from pathlib import Path + +PILOT_AREA_EXTENT = { + "type": "Polygon", + "coordinates": [30.743498637, 32.069186664, -25.201606226, -23.960197335], +} + +DEFAULT_CRS_ID = 4326 + +DOCUMENTATION_SITE = "https://kartoza.github.io/cplus-plugin" +USER_DOCUMENTATION_SITE = "https://kartoza.github.io/cplus-plugin/user/guide" +ABOUT_DOCUMENTATION_SITE = "https://kartoza.github.io/cplus-plugin/about/ci" +REPORT_DOCUMENTATION = ( + "https://kartoza.github.io/cplus-plugin/user/guide/#report-generating" +) + +OPTIONS_TITLE = "CPLUS" # Title in the QGIS settings +ICON_PATH = ":/plugins/cplus_plugin/icon.svg" +ICON_PDF = ( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/mActionSaveAsPDF.svg" +) +ICON_LAYOUT = ( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/mActionNewLayout.svg" +) +ICON_REPORT = ( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/mIconReport.svg" +) +ICON_HELP = ( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/mActionHelpContents_green.svg" +) + +ADD_LAYER_ICON_PATH = ":/plugins/cplus_plugin/cplus_left_arrow.svg" +REMOVE_LAYER_ICON_PATH = ":/plugins/cplus_plugin/cplus_right_arrow.svg" + +SCENARIO_OUTPUT_FILE_NAME = "cplus_scenario_output" +SCENARIO_OUTPUT_LAYER_NAME = "scenario_result" + +PILOT_AREA_SCENARIO_SYMBOLOGY = { + "Agroforestry": {"val": 1, "color": "#d80007"}, + "Alien Plant Removal": {"val": 2, "color": "#6f6f6f"}, + "Applied Nucleation": {"val": 3, "color": "#81c4ff"}, + "Assisted Natural Regeneration": {"val": 4, "color": "#e8ec18"}, + "Avoided Deforestation and Degradation": {"val": 5, "color": "#ff4c84"}, + "Avoided Wetland Conversion/Restoration": {"val": 6, "color": "#1f31d3"}, + "Bioproducts": {"val": 7, "color": "#67593f"}, + "Bush Thinning": {"val": 8, "color": "#30ff01"}, + "Direct Tree Seeding": {"val": 9, "color": "#bd6b70"}, + "Livestock Market Access": {"val": 10, "color": "#6c0009"}, + "Livestock Rangeland Management": {"val": 11, "color": "#ffa500"}, + "Natural Woodland Livestock Management": {"val": 12, "color": "#007018"}, + "Sustainable Crop Farming & Aquaponics": {"val": 13, "color": "#781a8b"}, +} + +IM_COLOUR_RAMPS = { + "Agroforestry": "Reds", + "Alien Plant Removal": "Greys", + "Alien_Plant_Removal": "Greys", + "Applied Nucleation": "PuBu", + "Applied_Nucleation": "PuBu", + "Assisted Natural Regeneration": "YlOrRd", + "Assisted_Natural_Regeneration": "YlOrRd", + "Avoided Deforestation and Degradation": "RdPu", + "Avoided_Deforestation_and_Degradation": "RdPu", + "Avoided Wetland Conversion/Restoration": "Blues", + "Avoided_Wetland_Conversion_Restoration": "Blues", + "Bioproducts": "BrBG", + "Bush Thinning": "BuGn", + "Bush_Thinning": "BuGn", + "Direct Tree Seeding": "PuRd", + "Direct_Tree_Seeding": "PuRd", + "Livestock Market Access": "Rocket", + "Livestock_Market_Access": "Rocket", + "Livestock Rangeland Management": "YlOrBr", + "Livestock_Rangeland_Management": "YlOrBr", + "Natural Woodland Livestock Management": "Greens", + "Natural_Woodland_Livestock_Management": "Greens", + "Sustainable Crop Farming & Aquaponics": "Purples", + "Sustainable_Crop_Farming_&_Aquaponics": "Purples", +} + +QGIS_GDAL_PROVIDER = "gdal" + +DEFAULT_LOGO_PATH = ( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/ci_logo.png" +) +CPLUS_LOGO_PATH = str( + os.path.normpath( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/cplus_logo.svg" + ) +) +CI_LOGO_PATH = str( + os.path.normpath( + os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + + "/icons/ci_logo.svg" + ) +) + +# Default template file name +TEMPLATE_NAME = "main.qpt" + +# Minimum sizes (in mm) for repeat items in the template +MINIMUM_ITEM_WIDTH = 100 +MINIMUM_ITEM_HEIGHT = 100 + +# Report font +REPORT_FONT_NAME = "Ubuntu" + +# IDs for the given tables in the report template +IMPLEMENTATION_MODEL_AREA_TABLE_ID = "implementation_model_area_table" +PRIORITY_GROUP_WEIGHT_TABLE_ID = "assigned_weights_table" + +# Initiliazing the plugin default data as found in the data directory +priority_layer_path = ( + Path(__file__).parent.parent.resolve() / "data" / "default" / + "priority_weighting_layers.json" +) + +with priority_layer_path.open("r") as fh: + priority_layers_dict = json.load(fh) +PRIORITY_LAYERS = priority_layers_dict["layers"] + + +pathways_path = ( + Path(__file__).parent.parent.resolve() / "data" / "default" / + "ncs_pathways.json" +) + +with pathways_path.open("r") as fh: + pathways_dict = json.load(fh) +# Path just contains the file name and is relative to +# {download_folder}/ncs_pathways +DEFAULT_NCS_PATHWAYS = pathways_dict["pathways"] + + +models_path = ( + Path(__file__).parent.parent.resolve() / "data" / "default" / + "implementation_models.json" +) + +with models_path.open("r") as fh: + models_dict = json.load(fh) + +DEFAULT_IMPLEMENTATION_MODELS = models_dict["models"] + + +PRIORITY_GROUPS = [ + { + "uuid": "dcfb3214-4877-441c-b3ef-8228ab6dfad3", + "name": "Biodiversity", + "description": "Placeholder text for bio diversity", + }, + { + "uuid": "8b9fb419-b6b8-40e8-9438-c82901d18cd9", + "name": "Livelihood", + "description": "Placeholder text for livelihood", + }, + { + "uuid": "21a30a80-eb49-4c5e-aff6-558123688e09", + "name": "Climate Resilience", + "description": "Placeholder text for climate resilience ", + }, + { + "uuid": "ae1791c3-93fd-4e8a-8bdf-8f5fced11ade", + "name": "Ecological infrastructure", + "description": "Placeholder text for ecological infrastructure", + }, + { + "uuid": "8cac9e25-98a8-4eae-a257-14a4ef8995d0", + "name": "Policy", + "description": "Placeholder text for policy", + }, + { + "uuid": "3a66c845-2f9b-482c-b9a9-bcfca8395ad5", + "name": "Finance - Years Experience", + "description": "Placeholder text for years of experience", + }, + { + "uuid": "c6dbfe09-b05c-4cfc-8fc0-fb63cfe0ceee", + "name": "Finance - Market Trends", + "description": "Placeholder text for market trends", + }, + { + "uuid": "3038cce0-3470-4b09-bb2a-f82071fe57fd", + "name": "Finance - Net Present value", + "description": "Placeholder text for net present value", + }, + { + "uuid": "3b2c7421-f879-48ef-a973-2aa3b1390694", + "name": "Finance - Carbon", + "description": "Placeholder text for finance carbon", + }, +] + +DEFAULT_REPORT_DISCLAIMER = ( + "The boundaries, names, and designations " + "used in this report do not imply official " + "endorsement or acceptance by Conservation " + "International Foundation, or its partner " + "organizations and contributors." +) +DEFAULT_REPORT_LICENSE = ( + "Creative Commons Attribution 4.0 International " "License (CC BY 4.0)" +) diff --git a/django_project/cplus/models.py b/django_project/cplus/models.py deleted file mode 100644 index 71a8362..0000000 --- a/django_project/cplus/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/django_project/cplus/models/__init__.py b/django_project/cplus/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/cplus/models/base.py b/django_project/cplus/models/base.py new file mode 100644 index 0000000..a04998b --- /dev/null +++ b/django_project/cplus/models/base.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- + +""" QGIS CPLUS plugin models. +""" + +import dataclasses +import datetime +from enum import Enum, IntEnum +import os.path +import typing +from uuid import UUID + +from qgis.core import ( + QgsColorBrewerColorRamp, + QgsColorRamp, + QgsCptCityColorRamp, + QgsFillSymbol, + QgsGradientColorRamp, + QgsLimitedRandomColorRamp, + QgsMapLayer, + QgsPresetSchemeColorRamp, + QgsRandomColorRamp, + QgsRasterLayer, + QgsVectorLayer, +) + +from cplus.definitions.constants import ( + COLOR_RAMP_PROPERTIES_ATTRIBUTE, + COLOR_RAMP_TYPE_ATTRIBUTE, + IM_LAYER_STYLE_ATTRIBUTE, + IM_SCENARIO_STYLE_ATTRIBUTE, +) + + +@dataclasses.dataclass +class SpatialExtent: + """Extent object that stores + the coordinates of the area of interest + """ + + bbox: typing.List[float] + + +class PRIORITY_GROUP(Enum): + """Represents priority groups types""" + + CARBON_IMPORTANCE = "Carbon importance" + BIODIVERSITY = "Biodiversity" + LIVELIHOOD = "Livelihood" + CLIMATE_RESILIENCE = "Climate Resilience" + ECOLOGICAL_INFRASTRUCTURE = "Ecological infrastructure" + POLICY = "Policy" + FINANCE_YEARS_EXPERIENCE = "Finance - Years Experience" + FINANCE_MARKET_TRENDS = "Finance - Market Trends" + FINANCE_NET_PRESENT_VALUE = "Finance - Net Present value" + FINANCE_CARBON = "Finance - Carbon" + + +@dataclasses.dataclass +class BaseModelComponent: + """Base class for common model item properties.""" + + uuid: UUID + name: str + description: str + + def __eq__(self, other: "BaseModelComponent") -> bool: + """Test equality of object with another BaseModelComponent + object using the attributes. + + :param other: BaseModelComponent object to compare with this object. + :type other: BaseModelComponent + + :returns: True if the all the attribute values match, else False. + :rtype: bool + """ + if self.uuid != other.uuid: + return False + + if self.name != other.name: + return False + + if self.description != other.description: + return False + + return True + + +BaseModelComponentType = typing.TypeVar( + "BaseModelComponentType", bound=BaseModelComponent +) + + +class LayerType(IntEnum): + """QGIS spatial layer type.""" + + RASTER = 0 + VECTOR = 1 + UNDEFINED = -1 + + +class ModelComponentType(Enum): + """Type of model component i.e. NCS pathway or + implementation model. + """ + + NCS_PATHWAY = "ncs_pathway" + IMPLEMENTATION_MODEL = "implementation_model" + UNKNOWN = "unknown" + + @staticmethod + def from_string(str_enum: str) -> "ModelComponentType": + """Creates an enum from the corresponding string equivalent. + + :param str_enum: String representing the model component type. + :type str_enum: str + + :returns: Component type enum corresponding to the given + string else unknown if not found. + :rtype: ModelComponentType + """ + if str_enum.lower() == "ncs_pathway": + return ModelComponentType.NCS_PATHWAY + elif str_enum.lower() == "implementation_model": + return ModelComponentType.IMPLEMENTATION_MODEL + + return ModelComponentType.UNKNOWN + + +@dataclasses.dataclass +class LayerModelComponent(BaseModelComponent): + """Base class for model components that support + a map layer. + """ + + path: str = "" + layer_type: LayerType = LayerType.UNDEFINED + user_defined: bool = False + + def __post_init__(self): + """Try to set the layer and layer type properties.""" + self.update_layer_type() + + def update_layer_type(self): + """Update the layer type if either the layer or + path properties have been set. + """ + layer = self.to_map_layer() + if layer is None: + return + + if not layer.isValid(): + return + + if isinstance(layer, QgsRasterLayer): + self.layer_type = LayerType.RASTER + + elif isinstance(layer, QgsVectorLayer): + self.layer_type = LayerType.VECTOR + + def to_map_layer(self) -> typing.Union[QgsMapLayer, None]: + """Constructs a map layer from the specified path. + + It will first check if the layer property has been set + else try to construct the layer from the path else return + None. + + :returns: Map layer corresponding to the set layer + property or specified path. + :rtype: QgsMapLayer + """ + if not os.path.exists(self.path): + return None + + layer = None + if self.layer_type == LayerType.RASTER: + layer = QgsRasterLayer(self.path, self.name) + + elif self.layer_type == LayerType.VECTOR: + layer = QgsVectorLayer(self.path, self.name) + + return layer + + def is_valid(self) -> bool: + """Checks if the corresponding map layer is valid. + + :returns: True if the map layer is valid, else False if map layer is + invalid or of None type. + :rtype: bool + """ + layer = self.to_map_layer() + if layer is None: + return False + + return layer.isValid() + + def __eq__(self, other) -> bool: + """Uses BaseModelComponent equality test rather than + what the dataclass default implementation will provide. + """ + return super().__eq__(other) + + +LayerModelComponentType = typing.TypeVar( + "LayerModelComponentType", bound=LayerModelComponent +) + + +@dataclasses.dataclass +class PriorityLayer(BaseModelComponent): + """Base class for model components storing priority weighting layers.""" + + groups: list + selected: bool = False + path: str = "" + + +@dataclasses.dataclass +class NcsPathway(LayerModelComponent): + """Contains information about an NCS pathway layer.""" + + carbon_paths: typing.List[str] = dataclasses.field(default_factory=list) + + def __eq__(self, other: "NcsPathway") -> bool: + """Test equality of NcsPathway object with another + NcsPathway object using the attributes. + + Excludes testing the map layer for equality. + + :param other: NcsPathway object to compare with this object. + :type other: NcsPathway + + :returns: True if all the attribute values match, else False. + :rtype: bool + """ + base_equality = super().__eq__(other) + if not base_equality: + return False + + if self.path != other.path: + return False + + if self.layer_type != other.layer_type: + return False + + if self.user_defined != other.user_defined: + return False + + return True + + def add_carbon_path(self, carbon_path: str) -> bool: + """Add a carbon layer path. + + Checks if the path has already been defined or if it exists + in the file system. + + :returns: True if the carbon layer path was successfully + added, else False if the path has already been defined + or does not exist in the file system. + :rtype: bool + """ + if carbon_path in self.carbon_paths: + return False + + if not os.path.exists(carbon_path): + return False + + self.carbon_paths.append(carbon_path) + + return True + + def carbon_layers(self) -> typing.List[QgsRasterLayer]: + """Returns the list of carbon layers whose path is defined under + the :py:attr:`~carbon_paths` attribute. + + The caller should check the validity of the layers or use + :py:meth:`~is_carbon_valid` function. + + :returns: Carbon layers for the NCS pathway or an empty list + if the path is not defined. + :rtype: list + """ + return [ + QgsRasterLayer(carbon_path) for carbon_path in self.carbon_paths] + + def is_carbon_valid(self) -> bool: + """Checks if the carbon layers are valid. + + :returns: True if all carbon layers are valid, else False if + even one is invalid. If there are no carbon layers defined, it will + always return True. + :rtype: bool + """ + is_valid = True + for cl in self.carbon_layers(): + if not cl.isValid(): + is_valid = False + break + + return is_valid + + def is_valid(self) -> bool: + """Additional check to include validity of carbon layers.""" + valid = super().is_valid() + if not valid: + return False + + carbon_valid = self.is_carbon_valid() + if not carbon_valid: + return False + + return True + + +@dataclasses.dataclass +class ImplementationModel(LayerModelComponent): + """Contains information about the implementation model + for a scenario. If the layer has been set then it will + not be possible to add NCS pathways unless the layer + is cleared. + Priority will be given to the layer property. + """ + + pathways: typing.List[NcsPathway] = dataclasses.field(default_factory=list) + priority_layers: typing.List[typing.Dict] = dataclasses.field( + default_factory=list) + layer_styles: dict = dataclasses.field(default_factory=dict) + style_pixel_value: int = -1 + + def __post_init__(self): + """Pre-checks on initialization.""" + super().__post_init__() + + # Ensure there are no duplicate pathways. + uuids = [str(p.uuid) for p in self.pathways] + + if len(set(uuids)) != len(uuids): + msg = "Duplicate pathways found in implementation model" + raise ValueError(f"{msg} {self.name}.") + + # Reset pathways if layer has also been set. + if self.to_map_layer() is not None and len(self.pathways) > 0: + self.pathways = [] + + def contains_pathway(self, pathway_uuid: str) -> bool: + """Checks if there is an NCS pathway matching the given UUID. + + :param pathway_uuid: UUID to search for in the collection. + :type pathway_uuid: str + + :returns: True if there is a matching NCS pathway, else False. + :rtype: bool + """ + ncs_pathway = self.pathway_by_uuid(pathway_uuid) + if ncs_pathway is None: + return False + + return True + + def add_ncs_pathway(self, ncs: NcsPathway) -> bool: + """Adds an NCS pathway object to the collection. + + :param ncs: NCS pathway to be added to the model. + :type ncs: NcsPathway + + :returns: True if the NCS pathway was successfully added, else False + if there was an existing NCS pathway object with a similar UUID or + the layer property had already been set. + """ + + if not ncs.is_valid(): + return False + + if self.contains_pathway(str(ncs.uuid)): + return False + + self.pathways.append(ncs) + + return True + + def clear_layer(self): + """Removes a reference to the layer URI defined in the path attribute. + """ + self.path = "" + + def remove_ncs_pathway(self, pathway_uuid: str) -> bool: + """Removes the NCS pathway with a matching UUID from the collection. + + :param pathway_uuid: UUID for the NCS pathway to be removed. + :type pathway_uuid: str + + :returns: True if the NCS pathway object was successfully removed, + else False if there is no object matching the given UUID. + :rtype: bool + """ + idxs = [ + i for i, p in enumerate(self.pathways) if + str(p.uuid) == pathway_uuid + ] + + if len(idxs) == 0: + return False + + rem_idx = idxs[0] + _ = self.pathways.pop(rem_idx) + + return True + + def pathway_by_uuid( + self, pathway_uuid: str) -> typing.Union[NcsPathway, None]: + """Returns an NCS pathway matching the given UUID. + + :param pathway_uuid: UUID for the NCS pathway to retrieve. + :type pathway_uuid: str + + :returns: NCS pathway object matching the given UUID else None if + not found. + :rtype: NcsPathway + """ + pathways = [p for p in self.pathways if str(p.uuid) == pathway_uuid] + + if len(pathways) == 0: + return None + + return pathways[0] + + def pw_layers(self) -> typing.List[QgsRasterLayer]: + """Returns the list of priority weighting layers wdefined under + the :py:attr:`~priority_layers` attribute. + + :returns: Priority layers for the implementation or an empty list + if the path is not defined. + :rtype: list + """ + return [ + QgsRasterLayer(layer.get("path")) for + layer in self.priority_layers + ] + + def is_pwls_valid(self) -> bool: + """Checks if the priority layers are valid. + + :returns: True if all priority layers are valid, else False if + even one is invalid. If there are no priority layers defined, it will + always return True. + :rtype: bool + """ + is_valid = True + for cl in self.pw_layers(): + if not cl.isValid(): + is_valid = False + break + + return is_valid + + def is_valid(self) -> bool: + """Includes an additional check to assert if NCS pathways have + been specified if the layer has not been set or is not valid. + + Does not check for validity of individual NCS pathways in the + collection. + """ + if self.to_map_layer() is not None: + return super().is_valid() + else: + if len(self.pathways) == 0: + return False + + if not self.is_pwls_valid(): + return False + + return True + + def scenario_layer_style_info(self) -> dict: + """Returns the fill symbol properties for styling the implementation + layer in the final scenario result. + + :returns: Fill symbol properties for the implementation model + styling in the scenario layer or an empty dictionary if there was + no definition found in the root style. + :rtype: dict + """ + if ( + len(self.layer_styles) == 0 or + IM_SCENARIO_STYLE_ATTRIBUTE not in self.layer_styles + ): + return dict() + + return self.layer_styles[IM_SCENARIO_STYLE_ATTRIBUTE] + + def model_layer_style_info(self) -> dict: + """Returns the color ramp properties for styling the implementation + layer resulting from a scenario run. + + :returns: Color ramp properties for the implementation model styling + or an empty dictionary if there was no definition found in the root + style. + :rtype: dict + """ + if ( + len(self.layer_styles) == 0 or + IM_LAYER_STYLE_ATTRIBUTE not in self.layer_styles + ): + return dict() + + return self.layer_styles[IM_LAYER_STYLE_ATTRIBUTE] + + def scenario_fill_symbol(self) -> typing.Union[QgsFillSymbol, None]: + """Creates a fill symbol for the implementation model in the scenario. + + :returns: Fill symbol for the implementation model in the scenario + or None if there was no definition found. + :rtype: QgsFillSymbol + """ + scenario_style_info = self.scenario_layer_style_info() + if len(scenario_style_info) == 0: + return None + + return QgsFillSymbol.createSimple(scenario_style_info) + + def model_color_ramp(self) -> typing.Union[QgsColorRamp, None]: + """Create a color ramp for styling the implementation layer resulting + from a scenario run. + + :returns: A color ramp for styling the implementation layer or None + if there was no definition found. + :rtype: QgsColorRamp + """ + model_layer_info = self.model_layer_style_info() + if len(model_layer_info) == 0: + return None + + ramp_info = model_layer_info.get( + COLOR_RAMP_PROPERTIES_ATTRIBUTE, None) + if ramp_info is None or len(ramp_info) == 0: + return None + + ramp_type = model_layer_info.get(COLOR_RAMP_TYPE_ATTRIBUTE, None) + if ramp_type is None: + return None + + # New ramp types will need to be added here manually + if ramp_type == QgsColorBrewerColorRamp.typeString(): + return QgsColorBrewerColorRamp.create(ramp_info) + elif ramp_type == QgsCptCityColorRamp.typeString(): + return QgsCptCityColorRamp.create(ramp_info) + elif ramp_type == QgsGradientColorRamp.typeString(): + return QgsGradientColorRamp.create(ramp_info) + elif ramp_type == QgsLimitedRandomColorRamp.typeString(): + return QgsLimitedRandomColorRamp.create(ramp_info) + elif ramp_type == QgsPresetSchemeColorRamp.typeString(): + return QgsPresetSchemeColorRamp.create(ramp_info) + elif ramp_type == QgsRandomColorRamp.typeString(): + return QgsRandomColorRamp() + + return None + + +class ScenarioState(Enum): + """Defines scenario analysis process states""" + + IDLE = 0 + RUNNING = 1 + STOPPED = 3 + FINISHED = 4 + TERMINATED = 5 + + +@dataclasses.dataclass +class Scenario(BaseModelComponent): + """Object for the handling + workflow scenario information. + """ + + extent: SpatialExtent + models: typing.List[ImplementationModel] + weighted_models: typing.List[ImplementationModel] + priority_layer_groups: typing.List + state: ScenarioState = ScenarioState.IDLE + + +@dataclasses.dataclass +class ScenarioResult: + """Scenario result details.""" + + scenario: Scenario + created_date: datetime.datetime = datetime.datetime.now() + analysis_output: typing.Dict = None + output_layer_name: str = "" + scenario_directory: str = "" diff --git a/django_project/cplus/models/helpers.py b/django_project/cplus/models/helpers.py new file mode 100644 index 0000000..d370f37 --- /dev/null +++ b/django_project/cplus/models/helpers.py @@ -0,0 +1,437 @@ +# -*- coding: utf-8 -*- + +"""Helper functions for supporting model management.""" + +from dataclasses import fields +import typing +import uuid + +from .base import ( + BaseModelComponent, + BaseModelComponentType, + ImplementationModel, + LayerModelComponent, + LayerModelComponentType, + LayerType, + NcsPathway, + SpatialExtent, +) +from ..definitions.constants import ( + CARBON_PATHS_ATTRIBUTE, + STYLE_ATTRIBUTE, + NAME_ATTRIBUTE, + DESCRIPTION_ATTRIBUTE, + LAYER_TYPE_ATTRIBUTE, + PATH_ATTRIBUTE, + PIXEL_VALUE_ATTRIBUTE, + PRIORITY_LAYERS_SEGMENT, + USER_DEFINED_ATTRIBUTE, + UUID_ATTRIBUTE, +) +from ..definitions.defaults import DEFAULT_CRS_ID + +from ..utils.helper import log + +from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsCoordinateTransform, + QgsProject, + QgsRectangle, +) + + +def model_component_to_dict( + model_component: BaseModelComponentType, uuid_to_str=True +) -> dict: + """Creates a dictionary containing the base attribute + name-value pairs from a model component object. + + :param model_component: Source model component object whose + values are to be mapped to the corresponding + attribute names. + :type model_component: BaseModelComponent + + :param uuid_to_str: Set True to convert the UUID to a + string equivalent, else False. Some serialization engines + such as JSON are unable to handle UUID objects hence the need + to convert to string. + :type uuid_to_str: bool + + :returns: Returns a dictionary item containing attribute + name-value pairs. + :rtype: dict + """ + model_uuid = model_component.uuid + if uuid_to_str: + model_uuid = str(model_uuid) + + return { + UUID_ATTRIBUTE: model_uuid, + NAME_ATTRIBUTE: model_component.name, + DESCRIPTION_ATTRIBUTE: model_component.description, + } + + +def create_model_component( + source_dict: dict, + model_cls: typing.Callable[[uuid.UUID, str, str], BaseModelComponentType], +) -> typing.Union[BaseModelComponentType, None]: + """Factory method for creating and setting attribute values + for a base model component object. + + :param source_dict: Dictionary containing attribute values. + :type source_dict: dict + + :param model_cls: Callable class that will be created based on the + input argument values from the dictionary. + :type model_cls: BaseModelComponent + + :returns: Base model component object with property values + derived from the dictionary. + :rtype: BaseModelComponent + """ + if not issubclass(model_cls, BaseModelComponent): + return None + + return model_cls( + uuid.UUID(source_dict[UUID_ATTRIBUTE]), + source_dict[NAME_ATTRIBUTE], + source_dict[DESCRIPTION_ATTRIBUTE], + ) + + +def create_layer_component( + source_dict, + model_cls: typing.Callable[ + [uuid.UUID, str, str, str, LayerType, bool], LayerModelComponentType + ], +) -> typing.Union[LayerModelComponent, None]: + """Factory method for creating a layer model component using + attribute values defined in a dictionary. + + :param source_dict: Dictionary containing property values. + :type source_dict: dict + + :param model_cls: Callable class that will be created based on the + input argument values from the dictionary. + :type model_cls: LayerModelComponent + + :returns: Layer model component object with property values set + from the dictionary. + :rtype: LayerModelComponent + """ + if UUID_ATTRIBUTE not in source_dict: + return None + + source_uuid = source_dict[UUID_ATTRIBUTE] + if isinstance(source_uuid, str): + source_uuid = uuid.UUID(source_uuid) + + kwargs = {} + if PATH_ATTRIBUTE in source_dict: + kwargs[PATH_ATTRIBUTE] = source_dict[PATH_ATTRIBUTE] + + if LAYER_TYPE_ATTRIBUTE in source_dict: + kwargs[LAYER_TYPE_ATTRIBUTE] = LayerType( + int(source_dict[LAYER_TYPE_ATTRIBUTE])) + + if USER_DEFINED_ATTRIBUTE in source_dict: + kwargs[USER_DEFINED_ATTRIBUTE] = bool( + source_dict[USER_DEFINED_ATTRIBUTE]) + + return model_cls( + source_uuid, + source_dict[NAME_ATTRIBUTE], + source_dict[DESCRIPTION_ATTRIBUTE], + **kwargs, + ) + + +def create_ncs_pathway(source_dict) -> typing.Union[NcsPathway, None]: + """Factory method for creating an NcsPathway object using + attribute values defined in a dictionary. + + :param source_dict: Dictionary containing property values. + :type source_dict: dict + + :returns: NCS pathway object with property values set + from the dictionary. + :rtype: NcsPathway + """ + ncs = create_layer_component(source_dict, NcsPathway) + + # We are checking because of the various iterations of the attributes + # in the NcsPathway class where some of these attributes might + # be missing. + if CARBON_PATHS_ATTRIBUTE in source_dict: + ncs.carbon_paths = source_dict[CARBON_PATHS_ATTRIBUTE] + + return ncs + + +def create_implementation_model( + source_dict) -> typing.Union[ImplementationModel, None]: + """Factory method for creating an implementation model using + attribute values defined in a dictionary. + + :param source_dict: Dictionary containing property values. + :type source_dict: dict + + :returns: Implementation model with property values set + from the dictionary. + :rtype: ImplementationModel + """ + implementation_model = create_layer_component( + source_dict, ImplementationModel) + if PRIORITY_LAYERS_SEGMENT in source_dict.keys(): + implementation_model.priority_layers = source_dict[ + PRIORITY_LAYERS_SEGMENT] + + # Set style + if STYLE_ATTRIBUTE in source_dict.keys(): + implementation_model.layer_styles = source_dict[STYLE_ATTRIBUTE] + + # Set styling pixel value + if PIXEL_VALUE_ATTRIBUTE in source_dict.keys(): + implementation_model.style_pixel_value = source_dict[ + PIXEL_VALUE_ATTRIBUTE] + + return implementation_model + + +def layer_component_to_dict( + layer_component: LayerModelComponentType, uuid_to_str=True +) -> dict: + """Creates a dictionary containing attribute + name-value pairs from a layer model component object. + + :param layer_component: Source layer model component object whose + values are to be mapped to the corresponding + attribute names. + :type layer_component: LayerModelComponent + + :param uuid_to_str: Set True to convert the UUID to a + string equivalent, else False. Some serialization engines + such as JSON are unable to handle UUID objects hence the need + to convert to string. + :type uuid_to_str: bool + + :returns: Returns a dictionary item containing attribute + name-value pairs. + :rtype: dict + """ + base_attrs = model_component_to_dict(layer_component, uuid_to_str) + base_attrs[PATH_ATTRIBUTE] = layer_component.path + base_attrs[LAYER_TYPE_ATTRIBUTE] = int(layer_component.layer_type) + base_attrs[USER_DEFINED_ATTRIBUTE] = layer_component.user_defined + + return base_attrs + + +def ncs_pathway_to_dict(ncs_pathway: NcsPathway, uuid_to_str=True) -> dict: + """Creates a dictionary containing attribute + name-value pairs from an NCS pathway object. + + This function has been retained for legacy support. + + :param ncs_pathway: Source NCS pathway object whose + values are to be mapped to the corresponding + attribute names. + :type ncs_pathway: NcsPathway + + :param uuid_to_str: Set True to convert the UUID to a + string equivalent, else False. Some serialization engines + such as JSON are unable to handle UUID objects hence the need + to convert to string. + :type uuid_to_str: bool + + :returns: Returns a dictionary item containing attribute + name-value pairs. + :rtype: dict + """ + base_ncs_dict = layer_component_to_dict(ncs_pathway, uuid_to_str) + base_ncs_dict[CARBON_PATHS_ATTRIBUTE] = ncs_pathway.carbon_paths + + return base_ncs_dict + + +def clone_layer_component( + layer_component: LayerModelComponent, + model_cls: typing.Callable[ + [uuid.UUID, str, str], LayerModelComponentType], +) -> typing.Union[LayerModelComponent, None]: + """Clones a layer-based model component. + + :param layer_component: Layer-based model component to clone. + :type layer_component: LayerModelComponent + + :param model_cls: Callable class that will be created based on the + input argument values from the dictionary. + :type model_cls: LayerModelComponent + + :returns: A new instance of the cloned model component. It + will return None if the input is not a layer-based model + component. + :rtype: LayerModelComponent + """ + if not isinstance(layer_component, LayerModelComponent): + return None + + cloned_component = model_cls( + layer_component.uuid, layer_component.name, + layer_component.description + ) + + for f in fields(layer_component): + attr_val = getattr(layer_component, f.name) + setattr(cloned_component, f.name, attr_val) + + return cloned_component + + +def clone_ncs_pathway(ncs: NcsPathway) -> NcsPathway: + """Creates a deep copy of the given NCS pathway. + + :param ncs: NCS pathway to clone. + :type ncs: NcsPathway + + :returns: A deep copy of the original NCS pathway object. + :rtype: NcsPathway + """ + return clone_layer_component(ncs, NcsPathway) + + +def clone_implementation_model( + implementation_model: ImplementationModel, +) -> ImplementationModel: + """Creates a deep copy of the given implementation model. + + :param implementation_model: Implementation model to clone. + :type implementation_model: ImplementationModel + + :returns: A deep copy of the original implementation model object. + :rtype: ImplementationModel + """ + imp_model = clone_layer_component( + implementation_model, ImplementationModel) + if imp_model is None: + return None + + pathways = implementation_model.pathways + cloned_pathways = [] + for p in pathways: + cloned_ncs = clone_ncs_pathway(p) + if cloned_ncs is not None: + cloned_pathways.append(cloned_ncs) + + imp_model.pathways = cloned_pathways + + return imp_model + + +def copy_layer_component_attributes( + target: LayerModelComponent, source: LayerModelComponent +) -> LayerModelComponent: + """Copies the attribute values of source to target. The uuid + attribute value is not copied as well as the layer attribute. + However, for the latter, the path is copied. + + :param target: Target object whose attribute values will be updated. + :type target: LayerModelComponent + + :param source: Source object whose attribute values will be copied to + the target. + :type source: LayerModelComponent + + :returns: Target object containing the updated attribute values apart + for the uuid whose value will not change. + :rtype: LayerModelComponent + """ + if not isinstance(target, LayerModelComponent) or not isinstance( + source, LayerModelComponent + ): + raise TypeError( + "Source or target objects are not of type 'LayerModelComponent'" + ) + + for f in fields(source): + # Exclude uuid + if f.name == UUID_ATTRIBUTE: + continue + attr_val = getattr(source, f.name) + setattr(target, f.name, attr_val) + + # Force layer to be set/updated + target.update_layer_type() + + return target + + +def extent_to_qgs_rectangle( + spatial_extent: SpatialExtent, +) -> typing.Union[QgsRectangle, None]: + """Returns a QgsRectangle object from the SpatialExtent object. + + If the SpatialExtent is invalid (i.e. less than four items) then it + will return None. + + :param spatial_extent: Spatial extent data model that defines the + scenario bounds. + :type spatial_extent: SpatialExtent + + :returns: QGIS rectangle defining the bounds for the scenario. + :rtype: QgsRectangle + """ + if len(spatial_extent.bbox) < 4: + return None + + return QgsRectangle( + spatial_extent.bbox[0], + spatial_extent.bbox[2], + spatial_extent.bbox[1], + spatial_extent.bbox[3], + ) + + +def extent_to_project_crs_extent( + spatial_extent: SpatialExtent, project: QgsProject = None +) -> typing.Union[QgsRectangle, None]: + """Transforms SpatialExtent model to an QGIS extent based + on the CRS of the given project. + + :param spatial_extent: Spatial extent data model that defines the + scenario bounds. + :type spatial_extent: SpatialExtent + + :param project: Project whose CRS will be used to determine + the values of the output extent. + :type project: QgsProject + + :returns: Output extent in the project's CRS. If the input extent + is invalid, this function will return None. + :rtype: QgsRectangle + """ + input_rect = extent_to_qgs_rectangle(spatial_extent) + if input_rect is None: + return None + + default_crs = QgsCoordinateReferenceSystem.fromEpsgId(DEFAULT_CRS_ID) + if not default_crs.isValid(): + return None + + if project is None: + project = QgsProject.instance() + + target_crs = project.crs() + if default_crs == target_crs: + # No need for transformation + return input_rect + + try: + coordinate_xform = QgsCoordinateTransform( + default_crs, project.crs(), project) + return coordinate_xform.transformBoundingBox(input_rect) + except Exception as e: + log(f"{e}, using the default input extent.") + + return input_rect diff --git a/django_project/cplus/models/report.py b/django_project/cplus/models/report.py new file mode 100644 index 0000000..4ef7591 --- /dev/null +++ b/django_project/cplus/models/report.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- + +""" Data models for report production.""" + +import dataclasses +import typing +from uuid import UUID + +from qgis.core import QgsFeedback + +from .base import Scenario + + +@dataclasses.dataclass +class ReportContext: + """Context information for generating a report.""" + + template_path: str + scenario: Scenario + name: str + scenario_output_dir: str + project_file: str + feedback: QgsFeedback + output_layer_name: str + + +@dataclasses.dataclass +class ReportSubmitStatus: + """Result of report submission process.""" + + status: bool + feedback: QgsFeedback + + +@dataclasses.dataclass +class ReportResult: + """Detailed result information from a report generation + run. + """ + + success: bool + scenario_id: UUID + output_dir: str + # Error messages + messages: typing.Tuple[str] = dataclasses.field(default_factory=tuple) + # Layout name + name: str = "" + + @property + def pdf_path(self) -> str: + """Returns the absolute path to the PDF file if the process + completed successfully. + + Caller needs to verify if the file actually exists in the + given location. + + :returns: Absolute path to the PDF file if the process + completed successfully else an empty string. + :rtype: str + """ + if not self.output_dir or not self.name: + return "" + + return f"{self.output_dir}/{self.name}.pdf" diff --git a/django_project/cplus/tasks/__init__.py b/django_project/cplus/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/cplus/tasks/analysis.py b/django_project/cplus/tasks/analysis.py new file mode 100644 index 0000000..ae6634b --- /dev/null +++ b/django_project/cplus/tasks/analysis.py @@ -0,0 +1,1621 @@ +import math +import os +import uuid +import logging +import traceback + +from pathlib import Path + +from qgis.core import ( + Qgis, + QgsCoordinateReferenceSystem, + QgsProcessing, + QgsProcessingContext, + QgsProcessingFeedback, + QgsRasterLayer, + QgsRectangle +) + +from qgis import processing +# from cplus.utils.conf import settings_manager +from cplus.utils.conf import TaskConfig, Settings +from cplus.models.helpers import clone_implementation_model +from cplus.models.base import ScenarioResult +from cplus.utils.helper import ( + align_rasters, + clean_filename, + tr, + log, + FileUtils, +) +from cplus.definitions.defaults import ( + SCENARIO_OUTPUT_FILE_NAME, +) + + +logger = logging.getLogger(__name__) + + +class ScenarioAnalysisTask(object): + """Prepares and runs the scenario analysis""" + + def __init__( + self, task_config: TaskConfig + ): + super().__init__() + self.task_config = task_config + self.analysis_scenario_name = self.task_config.scenario_name + self.analysis_scenario_description = self.task_config.scenario_desc + + self.analysis_implementation_models = ( + self.task_config.analysis_implementation_models + ) + self.analysis_priority_layers_groups = ( + self.task_config.priority_layer_groups + ) + self.analysis_extent = self.task_config.analysis_extent + self.analysis_extent_string = None + + self.analysis_weighted_ims = [] + self.scenario_result = None + self.scenario_directory = None + + self.success = True + self.output = None + self.error = None + self.status_message = None + + self.info_message = None + + self.processing_cancelled = False + self.feedback = QgsProcessingFeedback() + self.processing_context = QgsProcessingContext() + + self.scenario = self.task_config.scenario + + def run(self): + """Runs the main scenario analysis task operations""" + + base_dir = '/home/web/media' + self.runner_uuid = uuid.uuid4() + + self.scenario_directory = os.path.join( + f"{base_dir}", + f'{str(self.runner_uuid)}', + ) + + FileUtils.create_new_dir(self.scenario_directory) + + selected_pathway = None + pathway_found = False + + for model in self.analysis_implementation_models: + if pathway_found: + break + for pathway in model.pathways: + if pathway is not None: + pathway_found = True + selected_pathway = pathway + break + + target_layer = QgsRasterLayer( + selected_pathway.path, selected_pathway.name) + dest_crs = ( + target_layer.crs() + if selected_pathway and selected_pathway.path + else QgsCoordinateReferenceSystem("EPSG:4326") + ) + + processing_extent = QgsRectangle( + float(self.analysis_extent.bbox[0]), + float(self.analysis_extent.bbox[2]), + float(self.analysis_extent.bbox[1]), + float(self.analysis_extent.bbox[3]), + ) + + snapped_extent = self.align_extent(target_layer, processing_extent) + + extent_string = ( + f"{snapped_extent.xMinimum()},{snapped_extent.xMaximum()}," + f"{snapped_extent.yMinimum()},{snapped_extent.yMaximum()}" + f" [{dest_crs.authid()}]" + ) + + log( + "Original area of interest extent: " + f"{processing_extent.asWktPolygon()} \n" + ) + log( + "Snapped area of interest extent " + f"{snapped_extent.asWktPolygon()} \n" + ) + + # Run pathways layers snapping using a specified reference layer + snapping_enabled = self.task_config.get_value( + Settings.SNAPPING_ENABLED, False) + reference_layer = self.task_config.get_value(Settings.SNAP_LAYER, "") + reference_layer_path = Path(reference_layer) + if ( + snapping_enabled and + os.path.exists(reference_layer) and + reference_layer_path.is_file() + ): + self.snap_analysis_data( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + # Preparing all the pathways by adding them together with + # their carbon layers before creating + # their respective models. + + self.run_pathways_analysis( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + # Normalizing all the models pathways using the carbon coefficient and + # the pathway suitability index + + self.run_pathways_normalization( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + # Creating models from the normalized pathways + + self.run_models_analysis( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + # After creating models, we normalize them using the same coefficients + # used in normalizing their respective pathways. + + self.run_models_normalization( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + # Weighting the models with their corresponding + # priority weighting layers + weighted_models, result = self.run_models_weighting( + self.analysis_implementation_models, + self.analysis_priority_layers_groups, + extent_string, + ) + + self.analysis_weighted_ims = weighted_models + self.scenario.weighted_models = weighted_models + + # Post weighting analysis + self.run_models_cleaning(weighted_models, extent_string) + + # The highest position tool analysis + self.run_highest_position_analysis() + + return True + + def finished(self, result: bool): + """Calls the handler responsible for doing post analysis workflow. + + :param result: Whether the run() operation finished successfully + :type result: bool + """ + if result: + log("Finished from the main task \n") + else: + log(f"Error from task scenario task {self.error}") + + def set_status_message(self, message): + self.status_message = message + + def set_info_message(self, message, level=Qgis.Info): + self.info_message = message + + def set_custom_progress(self, value): + self.custom_progress = value + + def update_progress(self, value): + """Sets the value of the task progress + + :param value: Value to be set on the progress bar + :type value: float + """ + if not self.processing_cancelled: + self.set_custom_progress(value) + else: + self.feedback = QgsProcessingFeedback() + self.processing_context = QgsProcessingContext() + + def align_extent(self, raster_layer, target_extent): + """Snaps the passed extent to the models pathway layer pixel bounds + + :param raster_layer: The target layer that the passed extent will be + aligned with + :type raster_layer: QgsRasterLayer + + :param target_extent: Spatial extent that will be used + a target extent when doing alignment. + :type target_extent: QgsRectangle + """ + + try: + raster_extent = raster_layer.extent() + + x_res = raster_layer.rasterUnitsPerPixelX() + y_res = raster_layer.rasterUnitsPerPixelY() + + left = raster_extent.xMinimum() + x_res * math.floor( + (target_extent.xMinimum() - raster_extent.xMinimum()) / x_res + ) + right = raster_extent.xMinimum() + x_res * math.ceil( + (target_extent.xMaximum() - raster_extent.xMinimum()) / x_res + ) + bottom = raster_extent.yMinimum() + y_res * math.floor( + (target_extent.yMinimum() - raster_extent.yMinimum()) / y_res + ) + top = raster_extent.yMaximum() - y_res * math.floor( + (raster_extent.yMaximum() - target_extent.yMaximum()) / y_res + ) + + return QgsRectangle(left, bottom, right, top) + + except Exception as e: + log(traceback.format_exc()) + log( + tr( + f"Problem snapping area of " + f"interest extent, using the original extent," + f"{str(e)}" + ) + ) + + return target_extent + + def replace_nodata(self, layer_path, output_path, nodata_value): + """Adds nodata value info into the layer available + in the passed layer_path and save the layer in the passed output_path + path. + + The addition will replace any current nodata value available in + the input layer. + + :param layer_path: Input layer path + :type layer_path: str + + :param output_path: Output layer path + :type output_path: str + + :param nodata_value: Nodata value to be used + :type output_path: int + + :returns: If the process was successful + :rtype: bool + + """ + self.feedback = QgsProcessingFeedback() + self.feedback.progressChanged.connect(self.update_progress) + + alg_params = { + "COPY_SUBDATASETS": False, + "DATA_TYPE": 6, # Float32 + "EXTRA": "", + "INPUT": layer_path, + "NODATA": None, + "OPTIONS": "", + "TARGET_CRS": None, + "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, + } + translate_output = processing.run( + "gdal:translate", + alg_params, + context=self.processing_context, + feedback=self.feedback, + is_child_algorithm=True, + ) + + alg_params = { + "DATA_TYPE": 0, # Use Input Layer Data Type + "EXTRA": "", + "INPUT": translate_output["OUTPUT"], + "MULTITHREADING": False, + "NODATA": -9999, + "OPTIONS": "", + "RESAMPLING": 0, # Nearest Neighbour + "SOURCE_CRS": None, + "TARGET_CRS": None, + "TARGET_EXTENT": None, + "TARGET_EXTENT_CRS": None, + "TARGET_RESOLUTION": None, + "OUTPUT": output_path, + } + outputs = processing.run( + "gdal:warpreproject", + alg_params, + context=self.processing_context, + feedback=self.feedback, + is_child_algorithm=True, + ) + + return outputs is not None + + def run_pathways_analysis( + self, models, priority_layers_groups, extent, temporary_output=False + ): + """Runs the required model pathways analysis on the passed + implementation models. The analysis involves adding the pathways + carbon layers into the pathway layer. + + If the pathway layer has more than one carbon layer, the resulting + weighted pathway will contain the sum of the pathway layer values + with the average of the pathway carbon layers values. + + :param models: List of the selected implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers groups and + their values + :type priority_layers_groups: dict + + :param extent: The selected extent from user + :type extent: SpatialExtent + + :param temporary_output: Whether to save + the processing outputs as temporary files + :type temporary_output: bool + """ + if self.processing_cancelled: + return False + + self.set_status_message( + tr("Adding models pathways with carbon layers")) + + pathways = [] + models_paths = [] + + try: + for model in models: + if ( + not model.pathways and + (model.path is None or model.path == "") + ): + self.set_info_message( + tr( + f"No defined model pathways or a" + f" model layer for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + f"No defined model pathways or a " + f"model layer for the model {model.name}" + ) + return False + + for pathway in model.pathways: + if not (pathway in pathways): + pathways.append(pathway) + + if model.path is not None and model.path != "": + models_paths.append(model.path) + + if not pathways and len(models_paths) > 0: + self.run_pathways_normalization( + models, priority_layers_groups, extent) + return + + suitability_index = self.task_config.get_value( + Settings.PATHWAY_SUITABILITY_INDEX, 0) + carbon_coefficient = self.task_config.get_value( + Settings.CARBON_COEFFICIENT, 0.0) + + for pathway in pathways: + basenames = [] + layers = [] + path_basename = Path(pathway.path).stem + layers.append(pathway.path) + + file_name = clean_filename(pathway.name.replace(" ", "_")) + + if suitability_index > 0: + basenames.append( + f'{suitability_index} * "{path_basename}@1"') + else: + basenames.append(f'"{path_basename}@1"') + + carbon_names = [] + + if len(pathway.carbon_paths) <= 0: + continue + + new_carbon_directory = os.path.join( + self.scenario_directory, "pathways_carbon_layers" + ) + + FileUtils.create_new_dir(new_carbon_directory) + + output_file = os.path.join( + new_carbon_directory, + f"{file_name}_{str(uuid.uuid4())[:4]}.tif" + ) + + for carbon_path in pathway.carbon_paths: + carbon_full_path = Path(carbon_path) + if not carbon_full_path.exists(): + continue + layers.append(carbon_path) + carbon_names.append(f'"{carbon_full_path.stem}@1"') + + if len(carbon_names) == 1 and carbon_coefficient > 0: + basenames.append( + f"{carbon_coefficient} * ({carbon_names[0]})") + + # Setting up calculation to use carbon layers average when + # a pathway has more than one carbon layer. + if len(carbon_names) > 1 and carbon_coefficient > 0: + basenames.append( + f"{carbon_coefficient} * (" + f'({" + ".join(carbon_names)}) / ' + f"{len(pathway.carbon_paths)})" + ) + expression = " + ".join(basenames) + + if carbon_coefficient <= 0 and suitability_index <= 0: + self.run_pathways_normalization( + models, priority_layers_groups, extent + ) + return + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if temporary_output else + output_file + ) + + # Actual processing calculation + alg_params = { + "CELLSIZE": 0, + "CRS": None, + "EXPRESSION": expression, + "EXTENT": extent, + "LAYERS": layers, + "OUTPUT": output, + } + + log( + f"Used parameters for combining pathways" + f" and carbon layers generation: {alg_params} \n" + ) + + self.feedback = QgsProcessingFeedback() + + self.feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "qgis:rastercalculator", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + + pathway.path = results["OUTPUT"] + except Exception as e: + log(traceback.format_exc()) + log(f"Problem running pathway analysis, {e}") + self.error = e + # TODO: cancel task + # self.cancel() + + return True + + def snap_analysis_data(self, models, priority_layers_groups, extent): + """Snaps the passed models pathways, carbon layers and priority layers + to align with the reference layer set on the settings + manager. + + :param models: List of the selected implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers groups and + their values + :type priority_layers_groups: dict + + :param extent: The selected extent from user + :type extent: list + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return False + + self.set_status_message( + tr( + "Snapping the selected models pathways, " + "carbon layers and priority layers" + ) + ) + + pathways = [] + + try: + for model in models: + if ( + not model.pathways and + (model.path is None or model.path == "") + ): + self.set_info_message( + tr( + f"No defined model pathways or a" + f" model layer for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + f"No defined model pathways or a " + f"model layer for the model {model.name}" + ) + return False + + for pathway in model.pathways: + if not (pathway in pathways): + pathways.append(pathway) + + reference_layer_path = self.task_config.get_value( + Settings.SNAP_LAYER, '') + rescale_values = self.task_config.get_value( + Settings.RESCALE_VALUES, False) + resampling_method = self.task_config.get_value( + Settings.RESAMPLING_METHOD, 0) + + if pathways is not None and len(pathways) > 0: + snapped_pathways_directory = os.path.join( + self.scenario_directory, "pathways" + ) + + FileUtils.create_new_dir(snapped_pathways_directory) + + for pathway in pathways: + pathway_layer = QgsRasterLayer(pathway.path, pathway.name) + nodata_value = ( + pathway_layer.dataProvider().sourceNoDataValue(1) + ) + + if self.processing_cancelled: + return False + + # carbon layer snapping + + log(f"Snapping carbon layers from {pathway.name} pathway") + + if ( + pathway.carbon_paths is not None and + len(pathway.carbon_paths) > 0 + ): + snapped_carbon_directory = os.path.join( + self.scenario_directory, "carbon_layers" + ) + + FileUtils.create_new_dir(snapped_carbon_directory) + + snapped_carbon_paths = [] + + for carbon_path in pathway.carbon_paths: + carbon_layer = QgsRasterLayer( + carbon_path, f"{str(uuid.uuid4())[:4]}" + ) + nodata_value_carbon = ( + carbon_layer.dataProvider(). + sourceNoDataValue(1) + ) + + carbon_output_path = self.snap_layer( + carbon_path, + reference_layer_path, + extent, + snapped_carbon_directory, + rescale_values, + resampling_method, + nodata_value_carbon, + ) + + if carbon_output_path: + snapped_carbon_paths.append( + carbon_output_path) + else: + snapped_carbon_paths.append(carbon_path) + + pathway.carbon_paths = snapped_carbon_paths + + log(f"Snapping {pathway.name} pathway layer \n") + + # Pathway snapping + + output_path = self.snap_layer( + pathway.path, + reference_layer_path, + extent, + snapped_pathways_directory, + rescale_values, + resampling_method, + nodata_value, + ) + if output_path: + pathway.path = output_path + + for model in models: + log( + f"Snapping {len(model.priority_layers)} " + "priority weighting layers from model " + f"{model.name} with layers\n" + ) + + if ( + model.priority_layers is not None and + len(model.priority_layers) > 0 + ): + snapped_priority_directory = os.path.join( + self.scenario_directory, "priority_layers" + ) + + FileUtils.create_new_dir(snapped_priority_directory) + + priority_layers = [] + for priority_layer in model.priority_layers: + if priority_layer is None: + continue + + priority_layer_settings = ( + self.task_config.get_priority_layer( + priority_layer.get("uuid") + ) + ) + if priority_layer_settings is None: + continue + + priority_layer_path = ( + priority_layer_settings.get("path") + ) + + if not Path(priority_layer_path).exists(): + priority_layers.append(priority_layer) + continue + + layer = QgsRasterLayer( + priority_layer_path, f"{str(uuid.uuid4())[:4]}" + ) + nodata_value_priority = ( + layer.dataProvider().sourceNoDataValue(1) + ) + + priority_output_path = self.snap_layer( + priority_layer_path, + reference_layer_path, + extent, + snapped_priority_directory, + rescale_values, + resampling_method, + nodata_value_priority, + ) + + if priority_output_path: + priority_layer["path"] = priority_output_path + + priority_layers.append(priority_layer) + + model.priority_layers = priority_layers + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem snapping layers, {e} \n") + self.error = e + # TODO: cancel task + # self.cancel() + return False + + return True + + def snap_layer( + self, + input_path, + reference_path, + extent, + directory, + rescale_values, + resampling_method, + nodata_value, + ): + """Snaps the passed input layer using the reference layer and updates + the snap output no data value to be the same as + the original input layer no data value. + + :param input_path: Input layer source + :type input_path: str + + :param reference_path: Reference layer source + :type reference_path: str + + :param extent: Clip extent + :type extent: list + + :param directory: Absolute path of the output directory + for the snapped layers + :type directory: str + + :param rescale_values: Whether to rescale pixel values + :type rescale_values: bool + + :param resample_method: Method to use when resampling + :type resample_method: QgsAlignRaster.ResampleAlg + + :param nodata_value: Original no data value of the input layer + :type nodata_value: float + + """ + + input_result_path, reference_result_path = align_rasters( + input_path, + reference_path, + extent, + directory, + rescale_values, + resampling_method, + ) + + if input_result_path is not None: + result_path = Path(input_result_path) + + directory = result_path.parent + name = result_path.stem + + output_path = os.path.join(directory, f"{name}_final.tif") + + self.replace_nodata(input_result_path, output_path, nodata_value) + + return output_path + + def run_pathways_normalization( + self, models, priority_layers_groups, extent, temporary_output=False + ): + """Runs the normalization on the models pathways layers, + adjusting band values measured on different scale, the resulting scale + is computed using the below formula + Normalized_Pathway = (Carbon coefficient + Suitability index) * ( + (Model layer value) - (Model band minimum value)) / + (Model band maximum value - Model band minimum value)) + + If the carbon coefficient and suitability index are both zero then + the computation won't take them into account in the normalization + calculation. + + :param models: List of the analyzed implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers groups + and their values + :type priority_layers_groups: dict + + :param extent: selected extent from user + :type extent: str + + :param temporary_output: Whether to save + the processing outputs as temporary files + :type temporary_output: bool + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return False + + self.set_status_message(tr("Normalization of pathways")) + + pathways = [] + models_paths = [] + + try: + for model in models: + if ( + not model.pathways and + (model.path is None or model.path == "") + ): + self.set_info_message( + tr( + f"No defined model pathways or a" + f" model layer for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + f"No defined model pathways or a " + f"model layer for the model {model.name}" + ) + + return False + + for pathway in model.pathways: + if not (pathway in pathways): + pathways.append(pathway) + + if model.path is not None and model.path != "": + models_paths.append(model.path) + + if not pathways and len(models_paths) > 0: + self.run_models_analysis( + models, priority_layers_groups, extent) + return + + carbon_coefficient = self.task_config.get_value( + Settings.CARBON_COEFFICIENT, 0.0) + suitability_index = self.task_config.get_value( + Settings.PATHWAY_SUITABILITY_INDEX, 0) + + normalization_index = carbon_coefficient + suitability_index + + for pathway in pathways: + layers = [] + normalized_pathways_directory = os.path.join( + self.scenario_directory, "normalized_pathways" + ) + FileUtils.create_new_dir(normalized_pathways_directory) + file_name = clean_filename(pathway.name.replace(" ", "_")) + + output_file = os.path.join( + normalized_pathways_directory, + f"{file_name}_{str(uuid.uuid4())[:4]}.tif", + ) + + pathway_layer = QgsRasterLayer(pathway.path, pathway.name) + provider = pathway_layer.dataProvider() + band_statistics = provider.bandStatistics(1) + + min_value = band_statistics.minimumValue + max_value = band_statistics.maximumValue + + layer_name = Path(pathway.path).stem + + layers.append(pathway.path) + + log( + f"Found minimum {min_value} and " + f"maximum {max_value} for pathway " + f" \n" + ) + + if max_value < min_value: + raise Exception( + tr( + "Pathway contains " + "invalid minimum and maxmum band values" + ) + ) + + if normalization_index > 0: + expression = ( + f" {normalization_index} * " + f'("{layer_name}@1" - {min_value}) /' + f" ({max_value} - {min_value})" + ) + else: + expression = ( + f'("{layer_name}@1" - {min_value}) /' + f" ({max_value} - {min_value})" + ) + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if + temporary_output else output_file + ) + + # Actual processing calculation + alg_params = { + "CELLSIZE": 0, + "CRS": None, + "EXPRESSION": expression, + "EXTENT": extent, + "LAYERS": layers, + "OUTPUT": output, + } + + log( + "Used parameters for normalization of " + f"the pathways: {alg_params} \n" + ) + + self.feedback = QgsProcessingFeedback() + + self.feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "qgis:rastercalculator", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + + # self.replace_nodata(results["OUTPUT"], output_file, -9999) + + pathway.path = results["OUTPUT"] + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem normalizing pathways layers, {e} \n") + self.error = e + # TODO: cancel task + # self.cancel() + return False + + return True + + def run_models_analysis( + self, models, priority_layers_groups, extent, temporary_output=False + ): + """Runs the required model analysis on the passed + implementation models. + + :param models: List of the selected implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers + groups and their values + :type priority_layers_groups: dict + + :param extent: selected extent from user + :type extent: SpatialExtent + + :param temporary_output: Whether to save + the processing outputs as temporary files + :type temporary_output: bool + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return False + + self.set_status_message( + tr("Creating implementation models layers from pathways") + ) + + try: + for model in models: + ims_directory = os.path.join( + self.scenario_directory, "implementation_models" + ) + FileUtils.create_new_dir(ims_directory) + file_name = clean_filename(model.name.replace(" ", "_")) + + layers = [] + if ( + not model.pathways and + (model.path is None and model.path == "") + ): + self.set_info_message( + tr( + f"No defined model pathways or a" + f" model layer for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + f"No defined model pathways or a " + f"model layer for the model {model.name}" + ) + + return False + + output_file = os.path.join( + ims_directory, f"{file_name}_{str(uuid.uuid4())[:4]}.tif" + ) + + # Due to the implementation models base class + # model only one of the following blocks will be executed, + # the implementation model either contain a path or + # pathways + + if model.path is not None and model.path != "": + layers = [model.path] + + for pathway in model.pathways: + layers.append(pathway.path) + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if + temporary_output else output_file + ) + + # Actual processing calculation + + alg_params = { + "IGNORE_NODATA": True, + "INPUT": layers, + "EXTENT": extent, + "OUTPUT_NODATA_VALUE": -9999, + "REFERENCE_LAYER": layers[0] if len(layers) > 0 else None, + "STATISTIC": 0, # Sum + "OUTPUT": output, + } + + log( + f"Used parameters for " + f"implementation models generation: {alg_params} \n" + ) + + feedback = QgsProcessingFeedback() + + feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "native:cellstatistics", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + model.path = results["OUTPUT"] + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem creating models layers, {e}") + self.error = e + # TODO: cancel task + # self.cancel() + return False + + return True + + def run_models_normalization( + self, models, priority_layers_groups, extent, temporary_output=False + ): + """Runs the normalization analysis on the models layers, + adjusting band values measured on different scale, the resulting scale + is computed using the below formula + Normalized_Model = (Carbon coefficient + Suitability index) * ( + (Model layer value) - (Model band minimum value)) / + (Model band maximum value - Model band minimum value)) + + If the carbon coefficient and suitability index are both zero then + the computation won't take them into account in the normalization + calculation. + + :param models: List of the analyzed implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers groups + and their values + :type priority_layers_groups: dict + + :param extent: Selected area of interest extent + :type extent: str + + :param temporary_output: Whether to + save the processing outputs as temporary files + :type temporary_output: bool + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return False + + self.set_status_message( + tr("Normalization of the implementation models")) + + try: + for model in models: + if model.path is None or model.path == "": + if not self.processing_cancelled: + self.set_info_message( + tr( + "Problem when running models normalization, " + "there is no map layer " + f"for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + "Problem when running models normalization, " + "there is no map layer " + f"for the model {model.name}" + ) + else: + # If the user cancelled the processing + self.set_info_message( + tr("Processing has been cancelled by the user."), + level=Qgis.Critical, + ) + log("Processing has been cancelled by the user.") + + return False + + layers = [] + normalized_ims_directory = os.path.join( + self.scenario_directory, "normalized_ims" + ) + FileUtils.create_new_dir(normalized_ims_directory) + file_name = clean_filename(model.name.replace(" ", "_")) + + output_file = os.path.join( + normalized_ims_directory, + f"{file_name}_{str(uuid.uuid4())[:4]}.tif" + ) + + model_layer = QgsRasterLayer(model.path, model.name) + provider = model_layer.dataProvider() + band_statistics = provider.bandStatistics(1) + + min_value = band_statistics.minimumValue + max_value = band_statistics.maximumValue + + log( + f"Found minimum {min_value} and " + f"maximum {max_value} for model {model.name} \n" + ) + + layer_name = Path(model.path).stem + + layers.append(model.path) + + carbon_coefficient = self.task_config.get_value( + Settings.CARBON_COEFFICIENT, 0.0) + suitability_index = self.task_config.get_value( + Settings.PATHWAY_SUITABILITY_INDEX, 0) + + + normalization_index = carbon_coefficient + suitability_index + + if normalization_index > 0: + expression = ( + f" {normalization_index} * " + f'("{layer_name}@1" - {min_value}) /' + f" ({max_value} - {min_value})" + ) + + else: + expression = ( + f'("{layer_name}@1" - {min_value}) /' + f" ({max_value} - {min_value})" + ) + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if + temporary_output else output_file + ) + + # Actual processing calculation + alg_params = { + "CELLSIZE": 0, + "CRS": None, + "EXPRESSION": expression, + "EXTENT": extent, + "LAYERS": layers, + "OUTPUT": output, + } + + log( + "Used parameters for normalization of " + f"the models: {alg_params} \n" + ) + + feedback = QgsProcessingFeedback() + + feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "qgis:rastercalculator", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + model.path = results["OUTPUT"] + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem normalizing models layers, {e} \n") + self.error = e + # TODO: cancel task + # self.cancel() + return False + + return True + + def run_models_weighting( + self, models, priority_layers_groups, extent, temporary_output=False + ): + """Runs weighting analysis on the passed implementation models using + the corresponding models weighting analysis. + + :param models: List of the selected implementation models + :type models: typing.List[ImplementationModel] + + :param priority_layers_groups: Used priority layers groups + and their values + :type priority_layers_groups: dict + + :param extent: selected extent from user + :type extent: str + + :param temporary_output: Whether to save + the processing outputs as temporary files + :type temporary_output: bool + """ + + if self.processing_cancelled: + return [], False + + self.set_status_message(tr("Weighting implementation models")) + + weighted_models = [] + + try: + for original_model in models: + model = clone_implementation_model(original_model) + + if model.path is None or model.path == "": + self.set_info_message( + tr( + "Problem when running models weighting, " + "there is no map layer " + f"for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + "Problem when running models normalization, " + f"there is no map layer for the model {model.name}" + ) + + return [], False + + basenames = [] + layers = [] + + layers.append(model.path) + basenames.append(f'"{Path(model.path).stem}@1"') + + if not any(priority_layers_groups): + log( + "There are no defined priority layers in groups," + " skipping models weighting step." + ) + self.run_models_cleaning(extent) + return + + if ( + model.priority_layers is None or + model.priority_layers is [] + ): + log( + f"There are no associated " + f"priority weighting layers for model {model.name}" + ) + continue + + settings_model = self.task_config.get_implementation_model( + str(model.uuid) + ) + + for layer in settings_model.priority_layers: + if layer is None: + continue + + settings_layer = self.task_config.get_priority_layer( + layer.get("uuid") + ) + if settings_layer is None: + continue + + pwl = settings_layer.get("path") + + missing_pwl_message = ( + f"Path {pwl} for priority " + f"weighting layer {layer.get('name')} " + f"doesn't exist, skipping the layer " + f"from the model {model.name} weighting." + ) + if pwl is None: + log(missing_pwl_message) + continue + + pwl_path = Path(pwl) + + if not pwl_path.exists(): + log(missing_pwl_message) + continue + + path_basename = pwl_path.stem + + for priority_layer in \ + self.task_config.get_priority_layers(): + if priority_layer.get("name") == layer.get("name"): + for group in priority_layer.get("groups", []): + value = group.get("value") + coefficient = float(value) + if coefficient > 0: + if pwl not in layers: + layers.append(pwl) + basenames.append( + f'({coefficient}*"{path_basename}@1")' + ) + + if basenames is []: + return [], True + + weighted_ims_directory = os.path.join( + self.scenario_directory, "weighted_ims" + ) + + FileUtils.create_new_dir(weighted_ims_directory) + + file_name = clean_filename(model.name.replace(" ", "_")) + output_file = os.path.join( + weighted_ims_directory, + f"{file_name}_{str(uuid.uuid4())[:4]}.tif" + ) + expression = " + ".join(basenames) + + output = ( + QgsProcessing.TEMPORARY_OUTPUT if + temporary_output else output_file + ) + + # Actual processing calculation + alg_params = { + "CELLSIZE": 0, + "CRS": None, + "EXPRESSION": expression, + "EXTENT": extent, + "LAYERS": layers, + "OUTPUT": output, + } + + log( + "Used parameters for calculating " + f"weighting models {alg_params} \n" + ) + + feedback = QgsProcessingFeedback() + + feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return [], False + + results = processing.run( + "qgis:rastercalculator", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + model.path = results["OUTPUT"] + + weighted_models.append(model) + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem weighting implementation models, {e}\n") + self.error = e + # TODO: cancel task + # self.cancel() + return None, False + + return weighted_models, True + + def run_models_cleaning(self, models, extent=None): + """Cleans the weighted implementation models replacing + zero values with no-data as they are not statistical meaningful + for the scenario analysis. + + :param extent: Selected extent from user + :type extent: str + """ + + if self.processing_cancelled: + return False + + self.set_status_message( + tr("Updating weighted implementation models values")) + + try: + for model in models: + if model.path is None or model.path == "": + self.set_info_message( + tr( + "Problem when running models updates, " + "there is no map layer " + f"for the model {model.name}" + ), + level=Qgis.Critical, + ) + log( + f"Problem when running models updates, " + f"there is no map layer for the model {model.name}" + ) + + return False + + layers = [model.path] + + file_name = clean_filename(model.name.replace(" ", "_")) + + output_file = os.path.join( + self.scenario_directory, "weighted_ims") + output_file = os.path.join( + output_file, + f"{file_name}_{str(uuid.uuid4())[:4]}_cleaned.tif" + ) + + # Actual processing calculation + # The aim is to convert pixels values to no data, + # that is why we are using the sum operation + # with only one layer. + + alg_params = { + "IGNORE_NODATA": True, + "INPUT": layers, + "EXTENT": extent, + "OUTPUT_NODATA_VALUE": 0, + "REFERENCE_LAYER": layers[0] if len(layers) > 0 else None, + "STATISTIC": 0, # Sum + "OUTPUT": output_file, + } + + log( + "Used parameters for " + "updates on the weighted " + f"implementation models: {alg_params} \n" + ) + + feedback = QgsProcessingFeedback() + + feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + results = processing.run( + "native:cellstatistics", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + model.path = results["OUTPUT"] + + except Exception as e: + log(traceback.format_exc()) + log(f"Problem cleaning implementation models, {e}") + self.error = e + # TODO: cancel task + # self.cancel() + return False + + return True + + def run_highest_position_analysis(self): + """Runs the highest position analysis which is last step + in scenario analysis. Uses the models set by the current ongoing + analysis. + + """ + if self.processing_cancelled: + # Will not proceed if processing has been cancelled by the user + return + + passed_extent_box = self.analysis_extent.bbox + passed_extent = QgsRectangle( + passed_extent_box[0], + passed_extent_box[2], + passed_extent_box[1], + passed_extent_box[3], + ) + + self.scenario_result = ScenarioResult( + scenario=self.scenario, scenario_directory=self.scenario_directory + ) + + try: + layers = {} + + self.set_status_message(tr("Calculating the highest position")) + + for model in self.analysis_weighted_ims: + if model.path is not None and model.path != "": + raster_layer = QgsRasterLayer(model.path, model.name) + layers[model.name] = ( + raster_layer if raster_layer is not None else None + ) + else: + for pathway in model.pathways: + layers[model.name] = QgsRasterLayer(pathway.path) + + source_crs = QgsCoordinateReferenceSystem("EPSG:4326") + dest_crs = ( + list(layers.values())[0].crs() if + len(layers) > 0 else source_crs + ) + + extent_string = ( + f"{passed_extent.xMinimum()},{passed_extent.xMaximum()}," + f"{passed_extent.yMinimum()},{passed_extent.yMaximum()}" + f" [{dest_crs.authid()}]" + ) + + output_file = os.path.join( + self.scenario_directory, + f"{SCENARIO_OUTPUT_FILE_NAME}_" + f"{str(self.scenario.uuid)[:4]}.tif", + ) + + # Preparing the input rasters for the highest position + # analysis in a correct order + + models_names = [model.name for + model in self.analysis_weighted_ims] + all_models = sorted( + self.analysis_weighted_ims, + key=lambda model_instance: model_instance.style_pixel_value, + ) + for index, model in enumerate(all_models): + model.style_pixel_value = index + 1 + + all_models_names = [model.name for model in all_models] + sources = [] + + for model_name in all_models_names: + if model_name in models_names: + sources.append(layers[model_name].source()) + + log(f"Layers sources {[Path(source).stem for source in sources]}") + + alg_params = { + "IGNORE_NODATA": True, + "INPUT_RASTERS": sources, + "EXTENT": extent_string, + "OUTPUT_NODATA_VALUE": -9999, + "REFERENCE_LAYER": list(layers.values())[0] + if len(layers) >= 1 + else None, + "OUTPUT": output_file, + } + + log( + "Used parameters for " + f"highest position analysis {alg_params} \n" + ) + + self.feedback = QgsProcessingFeedback() + + self.feedback.progressChanged.connect(self.update_progress) + + if self.processing_cancelled: + return False + + self.output = processing.run( + "native:highestpositioninrasterstack", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + + except Exception as err: + log(traceback.format_exc()) + log( + tr( + "An error occurred when running task for " + 'scenario analysis, error message "{}"'.format(str(err)) + ) + ) + self.error = err + # TODO: cancel task + # self.cancel() + return False + + return True diff --git a/django_project/cplus/tests.py b/django_project/cplus/tests.py index 7ce503c..a39b155 100644 --- a/django_project/cplus/tests.py +++ b/django_project/cplus/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/django_project/cplus/utils/__init__.py b/django_project/cplus/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_project/cplus/utils/conf.py b/django_project/cplus/utils/conf.py new file mode 100644 index 0000000..4811d63 --- /dev/null +++ b/django_project/cplus/utils/conf.py @@ -0,0 +1,1231 @@ +import contextlib +import dataclasses +import enum +import json +import os.path +from pathlib import Path +import typing +import uuid + +from qgis.PyQt import QtCore +from qgis.core import QgsSettings + +from cplus.definitions.defaults import PRIORITY_LAYERS + +from cplus.definitions.constants import ( + STYLE_ATTRIBUTE, + NCS_CARBON_SEGMENT, + NCS_PATHWAY_SEGMENT, + PATH_ATTRIBUTE, + PATHWAYS_ATTRIBUTE, + PIXEL_VALUE_ATTRIBUTE, + PRIORITY_LAYERS_SEGMENT, + UUID_ATTRIBUTE, +) + +from cplus.models.base import ( + ImplementationModel, + NcsPathway, + Scenario, + SpatialExtent, + LayerType +) +from cplus.models.helpers import ( + create_implementation_model, + create_ncs_pathway, + layer_component_to_dict, + ncs_pathway_to_dict, +) + +from cplus.utils.helper import log + + +@contextlib.contextmanager +def qgis_settings(group_root: str, settings=None): + """Context manager to help defining groups when creating QgsSettings. + + :param group_root: Name of the root group for the settings + :type group_root: str + + :param settings: QGIS settings to use + :type settings: QgsSettings + + :yields: Instance of the created settings + :ytype: QgsSettings + """ + if settings is None: + settings = QgsSettings() + settings.beginGroup(group_root) + try: + yield settings + finally: + settings.endGroup() + + +@dataclasses.dataclass +class ScenarioSettings(Scenario): + """Plugin Scenario settings.""" + + @classmethod + def from_qgs_settings(cls, identifier: str, settings: QgsSettings): + """Reads QGIS settings and parses them into a scenario + settings instance with the respective settings values as properties. + + :param identifier: Scenario identifier + :type identifier: str + + :param settings: Scenario identifier + :type settings: QgsSettings + + :returns: Scenario settings object + :rtype: ScenarioSettings + """ + + return cls( + uuid=uuid.UUID(identifier), + name=settings.value("name", None), + description=settings.value("description", None), + ) + + @classmethod + def get_scenario_extent(cls): + """Fetches Scenario extent from + the passed scenario settings. + + + :returns: Spatial extent instance extent + :rtype: SpatialExtent + """ + spatial_key = "extent/spatial" + + with qgis_settings(spatial_key, cls) as settings: + bbox = settings.value("bbox", None) + spatial_extent = SpatialExtent(bbox=bbox) + + return spatial_extent + + +class Settings(enum.Enum): + """Plugin settings names""" + + DOWNLOAD_FOLDER = "download_folder" + REFRESH_FREQUENCY = "refresh/period" + REFRESH_FREQUENCY_UNIT = "refresh/unit" + REFRESH_LAST_UPDATE = "refresh/last_update" + REFRESH_STATE = "refresh/state" + + # Report settings + REPORT_ORGANIZATION = "report/organization" + REPORT_CONTACT_EMAIL = "report/email" + REPORT_WEBSITE = "report/website" + REPORT_CUSTOM_LOGO = "report/custom_logo" + REPORT_CPLUS_LOGO = "report/cplus_logo" + REPORT_CI_LOGO = "report/ci_logo" + REPORT_LOGO_DIR = "report/logo_dir" + REPORT_FOOTER = "report/footer" + REPORT_DISCLAIMER = "report/disclaimer" + REPORT_LICENSE = "report/license" + + # Last selected data directory + LAST_DATA_DIR = "last_data_dir" + + # Advanced settings + BASE_DIR = "advanced/base_dir" + + # Scenario basic details + SCENARIO_NAME = "scenario_name" + SCENARIO_DESCRIPTION = "scenario_description" + SCENARIO_EXTENT = "scenario_extent" + + # Coefficient for carbon layers + CARBON_COEFFICIENT = "carbon_coefficient" + + # Pathway suitability index value + PATHWAY_SUITABILITY_INDEX = "pathway_suitability_index" + + # Snapping values + SNAPPING_ENABLED = "snapping_enabled" + SNAP_LAYER = "snap_layer" + ALLOW_RESAMPLING = "snap_resampling" + RESCALE_VALUES = "snap_rescale" + RESAMPLING_METHOD = "snap_method" + SNAP_PIXEL_VALUE = "snap_pixel_value" + + + +class SettingsManager(QtCore.QObject): + """Manages saving/loading settings for the plugin in QgsSettings.""" + + BASE_GROUP_NAME: str = "cplus_plugin" + SCENARIO_GROUP_NAME: str = "scenarios" + PRIORITY_GROUP_NAME: str = "priority_groups" + PRIORITY_LAYERS_GROUP_NAME: str = "priority_layers" + NCS_PATHWAY_BASE: str = "ncs_pathways" + + IMPLEMENTATION_MODEL_BASE: str = "implementation_models" + + settings = QgsSettings() + + scenarios_settings_updated = QtCore.pyqtSignal() + priority_layers_changed = QtCore.pyqtSignal() + settings_updated = QtCore.pyqtSignal([str, object], [Settings, object]) + + def set_value(self, name: str, value): + """Adds a new setting key and value on the plugin specific settings. + + :param name: Name of setting key + :type name: str + + :param value: Value of the setting + :type value: Any + """ + self.settings.setValue(f"{self.BASE_GROUP_NAME}/{name}", value) + if isinstance(name, Settings): + name = name.value + + self.settings_updated.emit(name, value) + + def get_value(self, name: str, default=None, setting_type=None): + """Gets value of the setting with the passed name. + + :param name: Name of setting key + :type name: str + + :param default: Default value returned when + the setting key does not exist + :type default: Any + + :param setting_type: Type of the store setting + :type setting_type: Any + + :returns: Value of the setting + :rtype: Any + """ + if setting_type: + return self.settings.value( + f"{self.BASE_GROUP_NAME}/{name}", default, setting_type + ) + return self.settings.value(f"{self.BASE_GROUP_NAME}/{name}", default) + + def find_settings(self, name): + """Returns the plugin setting keys from the + plugin root group that matches the passed name + + :param name: Setting name to search for + :type name: str + + :returns result: List of the matching settings names + :rtype result: list + """ + + result = [] + with qgis_settings(f"{self.BASE_GROUP_NAME}") as settings: + for settings_name in settings.childKeys(): + if name in settings_name: + result.append(settings_name) + return result + + def remove(self, name): + """Remove the setting with the specified name. + + :param name: Name of the setting key + :type name: str + """ + self.settings.remove(f"{self.BASE_GROUP_NAME}/{name}") + + def delete_settings(self): + """Deletes the all the plugin settings.""" + self.settings.remove(f"{self.BASE_GROUP_NAME}") + + def _get_scenario_settings_base(self, identifier): + """Gets the scenario settings base url. + + :param identifier: Scenario settings identifier + :type identifier: uuid.UUID + + :returns: Scenario settings base group + :rtype: str + """ + return ( + f"{self.BASE_GROUP_NAME}/" + f"{self.SCENARIO_GROUP_NAME}/" + f"{str(identifier)}" + ) + + def save_scenario(self, scenario_settings): + """Save the passed scenario settings into the plugin settings + + :param scenario_settings: Scenario settings + :type scenario_settings: ScenarioSettings + """ + settings_key = self._get_scenario_settings_base(scenario_settings.uuid) + + self.save_scenario_extent(settings_key, scenario_settings.extent) + + with qgis_settings(settings_key) as settings: + settings.setValue("name", scenario_settings.name) + settings.setValue("description", scenario_settings.description) + settings.setValue("uuid", scenario_settings.uuid) + + def save_scenario_extent(self, key, extent): + """Saves the scenario extent into plugin settings + using the provided settings group key. + + :param key: Scenario extent + :type key: SpatialExtent + + :param extent: QgsSettings group key + :type extent: str + + Args: + extent (SpatialExtent): Scenario extent + key (str): QgsSettings group key + """ + spatial_extent = extent.spatial.bbox + + spatial_key = f"{key}/extent/spatial/" + with qgis_settings(spatial_key) as settings: + settings.setValue("bbox", spatial_extent) + + def get_scenario(self, identifier): + """Retrieves the scenario that matches the passed identifier. + + :param identifier: Scenario identifier + :type identifier: str + + :returns: Scenario settings instance + :rtype: ScenarioSettings + """ + + settings_key = self._get_scenario_settings_base(identifier) + with qgis_settings(settings_key) as settings: + scenario_settings = ScenarioSettings.from_qgs_settings( + str(identifier), settings + ) + return scenario_settings + + def get_scenario_by_id(self, scenario_id): + """Retrieves the first scenario that matched the passed scenario id. + + :param scenario_id: Scenario id + :type scenario_id: str + + :returns: Scenario settings instance + :rtype: ScenarioSettings + """ + + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.SCENARIO_GROUP_NAME}" + ) as settings: + for scenario_uuid in settings.childGroups(): + scenario_settings_key = ( + self._get_scenario_settings_base(scenario_uuid) + ) + with ( + qgis_settings(scenario_settings_key) + ) as scenario_settings: + scenario = ScenarioSettings.from_qgs_settings( + scenario_uuid, scenario_settings + ) + if scenario.id == scenario_id: + return scenario + return None + + def get_scenarios(self): + """Gets all the available scenarios settings in the plugin. + + :returns: List of the scenario settings instances + :rtype: list + """ + result = [] + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.SCENARIO_GROUP_NAME}" + ) as settings: + for scenario_uuid in settings.childGroups(): + scenario_settings_key = self._get_scenario_settings_base( + scenario_uuid + ) + with qgis_settings(scenario_settings_key) as scenario_settings: + scenario = ScenarioSettings.from_qgs_settings( + scenario_uuid, scenario_settings + ) + scenario.extent = self.get_scenario_ + result.append( + ScenarioSettings.from_qgs_settings( + scenario_uuid, scenario_settings) + ) + return result + + def delete_all_scenarios(self): + """Deletes all the plugin scenarios settings.""" + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.SCENARIO_GROUP_NAME}" + ) as settings: + for scenario_name in settings.childGroups(): + settings.remove(scenario_name) + + def _get_priority_layers_settings_base(self, identifier) -> str: + """Gets the priority layers settings base url. + + :param identifier: Priority layers settings identifier + :type identifier: uuid.UUID + + :returns: Priority layers settings base group + :rtype: str + """ + return ( + f"{self.BASE_GROUP_NAME}/" + f"{self.PRIORITY_LAYERS_GROUP_NAME}/" + f"{str(identifier)}" + ) + + def get_priority_layer(self, identifier) -> typing.Dict: + """Retrieves the priority layer that matches the passed identifier. + + :param identifier: Priority layers identifier + :type identifier: uuid.UUID + + :returns: Priority layer dict + :rtype: dict + """ + priority_layer = None + + settings_key = self._get_priority_layers_settings_base(identifier) + with qgis_settings(settings_key) as settings: + groups_key = f"{settings_key}/groups" + groups = [] + + if len(settings.childKeys()) <= 0: + return priority_layer + + with qgis_settings(groups_key) as groups_settings: + for name in groups_settings.childGroups(): + group_settings_key = f"{groups_key}/{name}" + with qgis_settings(group_settings_key) as group_settings: + stored_group = {} + stored_group["uuid"] = group_settings.value("uuid") + stored_group["name"] = group_settings.value("name") + stored_group["value"] = group_settings.value("value") + groups.append(stored_group) + + priority_layer = {"uuid": str(identifier)} + priority_layer["name"] = settings.value("name") + priority_layer["description"] = settings.value("description") + priority_layer["path"] = settings.value("path") + priority_layer["selected"] = settings.value("selected", type=bool) + priority_layer["user_defined"] = settings.value( + "user_defined", defaultValue=True, type=bool + ) + priority_layer["groups"] = groups + return priority_layer + + def get_priority_layers(self) -> typing.List: + """Gets all the available priority layers in the plugin. + + :returns: Priority layers list + :rtype: list + """ + priority_layer_list = [] + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}" + ) as settings: + for scenario_uuid in settings.childGroups(): + priority_layer_settings = ( + self._get_priority_layers_settings_base(scenario_uuid) + ) + with ( + qgis_settings(priority_layer_settings) + ) as priority_settings: + groups_key = f"{priority_layer_settings}/groups" + groups = [] + + with qgis_settings(groups_key) as groups_settings: + for name in groups_settings.childGroups(): + group_settings_key = f"{groups_key}/{name}" + with ( + qgis_settings(group_settings_key) + ) as group_settings: + stored_group = {} + stored_group["uuid"] = ( + group_settings.value("uuid") + ) + stored_group["name"] = ( + group_settings.value("name") + ) + stored_group["value"] = ( + group_settings.value("value") + ) + groups.append(stored_group) + layer = { + "uuid": scenario_uuid, + "name": priority_settings.value("name"), + "description": priority_settings.value("description"), + "path": priority_settings.value("path"), + "selected": priority_settings.value( + "selected", type=bool), + "user_defined": priority_settings.value( + "user_defined", defaultValue=True, type=bool + ), + "groups": groups, + } + priority_layer_list.append(layer) + return priority_layer_list + + def find_layer_by_name(self, name) -> typing.Dict: + """Finds a priority layer setting inside + the plugin QgsSettings by name. + + :param name: Priority layers identifier + :type name: str + + :returns: Priority layers dict + :rtype: dict + """ + found_id = None + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}" + ) as settings: + for layer_id in settings.childGroups(): + layer_settings_key = ( + self._get_priority_layers_settings_base(layer_id) + ) + with qgis_settings(layer_settings_key) as layer_settings: + layer_name = layer_settings.value("name") + if layer_name == name: + found_id = uuid.UUID(layer_id) + break + + return ( + self.get_priority_layer(found_id) if + found_id is not None else None + ) + + def find_layers_by_group(self, group) -> typing.List: + """Finds priority layers inside the plugin QgsSettings + that contain the passed group. + + :param group: Priority group name + :type group: str + + :returns: Priority layers list + :rtype: list + """ + layers = [] + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}" + ) as settings: + for layer_id in settings.childGroups(): + priority_layer_settings = ( + self._get_priority_layers_settings_base(layer_id) + ) + with qgis_settings(priority_layer_settings): + groups_key = f"{priority_layer_settings}/groups" + + with qgis_settings(groups_key) as groups_settings: + for name in groups_settings.childGroups(): + group_settings_key = f"{groups_key}/{name}" + with ( + qgis_settings(group_settings_key) + ) as group_settings: + if group == group_settings.value("name"): + layers.append( + self.get_priority_layer(layer_id)) + return layers + + def save_priority_layer(self, priority_layer): + """Save the priority layer into the plugin settings. + Updates the layer with new priority groups. + + Note: Emits priority_layers_changed signal + + :param priority_layer: Priority layer + :type priority_layer: dict + """ + settings_key = ( + self._get_priority_layers_settings_base(priority_layer["uuid"]) + ) + + with qgis_settings(settings_key) as settings: + groups = priority_layer.get("groups", []) + settings.setValue("name", priority_layer["name"]) + settings.setValue("description", priority_layer["description"]) + settings.setValue("path", priority_layer["path"]) + settings.setValue( + "selected", priority_layer.get("selected", False)) + settings.setValue( + "user_defined", priority_layer.get("user_defined", True)) + groups_key = f"{settings_key}/groups" + with qgis_settings(groups_key) as groups_settings: + for group_id in groups_settings.childGroups(): + groups_settings.remove(group_id) + for group in groups: + group_key = f"{groups_key}/{group['name']}" + with qgis_settings(group_key) as group_settings: + group_settings.setValue("uuid", group.get("uuid")) + group_settings.setValue("name", group["name"]) + group_settings.setValue("value", group["value"]) + + self.priority_layers_changed.emit() + + def set_current_priority_layer(self, identifier): + """Set current priority layer + + :param identifier: Priority layer identifier + :type identifier: str + """ + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}/" + ) as settings: + for priority_layer in settings.childGroups(): + settings_key = ( + self._get_priority_layers_settings_base(identifier) + ) + with qgis_settings(settings_key) as layer_settings: + layer_settings.setValue( + "selected", str(priority_layer) == str(identifier) + ) + + def delete_priority_layers(self): + """Deletes all the plugin priority weighting layers settings.""" + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}" + ) as settings: + for priority_layer in settings.childGroups(): + settings.remove(priority_layer) + + def delete_priority_layer(self, identifier): + """Removes priority layer that match the passed identifier + + :param identifier: Priority layer identifier + :type identifier: str + """ + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_LAYERS_GROUP_NAME}/" + ) as settings: + for priority_layer in settings.childGroups(): + if str(priority_layer) == str(identifier): + settings.remove(priority_layer) + + def _get_priority_groups_settings_base(self, identifier) -> str: + """Gets the priority group settings base url. + + :param identifier: Priority group settings identifier + :type identifier: str + + :returns: Priority groups settings base group + :rtype: str + + """ + return ( + f"{self.BASE_GROUP_NAME}/" + f"{self.PRIORITY_GROUP_NAME}/" + f"{str(identifier)}" + ) + + def find_group_by_name(self, name) -> typing.Dict: + """Finds a priority group setting inside + the plugin QgsSettings by name. + + :param name: Name of the group + :type name: str + + :returns: Priority group + :rtype: typing.Dict + """ + + found_id = None + + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_GROUP_NAME}" + ) as settings: + for group_id in settings.childGroups(): + group_settings_key = ( + self._get_priority_groups_settings_base(group_id) + ) + with qgis_settings(group_settings_key) as group_settings_key: + group_name = group_settings_key.value("name") + if group_name == name: + found_id = uuid.UUID(group_id) + break + + return self.get_priority_group(found_id) + + def get_priority_group(self, identifier) -> typing.Dict: + """Retrieves the priority group that matches the passed identifier. + + :param identifier: Priority group identifier + :type identifier: str + + :returns: Priority group + :rtype: typing.Dict + """ + + if identifier is None: + return None + + settings_key = self._get_priority_groups_settings_base(identifier) + with qgis_settings(settings_key) as settings: + priority_group = {"uuid": identifier} + priority_group["name"] = settings.value("name") + priority_group["value"] = settings.value("value") + priority_group["description"] = settings.value("description") + return priority_group + + def get_priority_groups(self) -> typing.List[typing.Dict]: + """Gets all the available priority groups in the plugin. + + :returns: List of the priority groups instances + :rtype: list + """ + priority_groups = [] + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_GROUP_NAME}" + ) as settings: + for group_uuid in settings.childGroups(): + priority_layer_settings = ( + self._get_priority_groups_settings_base(group_uuid) + ) + with ( + qgis_settings(priority_layer_settings) + ) as priority_settings: + group = { + "uuid": group_uuid, + "name": priority_settings.value("name"), + "value": priority_settings.value("value"), + "description": priority_settings.value("description"), + } + priority_groups.append(group) + return priority_groups + + def save_priority_group(self, priority_group): + """Save the priority group into the plugin settings + + :param priority_group: Priority group + :type priority_group: str + """ + + settings_key = ( + self._get_priority_groups_settings_base(priority_group["uuid"]) + ) + + with qgis_settings(settings_key) as settings: + settings.setValue("name", priority_group["name"]) + settings.setValue("value", priority_group["value"]) + settings.setValue( + "description", priority_group.get("description")) + + def delete_priority_group(self, identifier): + """Removes priority group that match the passed identifier + + :param identifier: Priority group identifier + :type identifier: str + """ + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_GROUP_NAME}/" + ) as settings: + for priority_group in settings.childGroups(): + if str(priority_group) == str(identifier): + settings.remove(priority_group) + + def delete_priority_groups(self): + """Deletes all the plugin priority groups settings.""" + with qgis_settings( + f"{self.BASE_GROUP_NAME}/" f"{self.PRIORITY_GROUP_NAME}" + ) as settings: + for priority_group in settings.childGroups(): + settings.remove(priority_group) + + def _get_ncs_pathway_settings_base(self) -> str: + """Returns the path for NCS pathway settings. + + :returns: Base path to NCS pathway group. + :rtype: str + """ + return f"{self.BASE_GROUP_NAME}/" f"{NCS_PATHWAY_SEGMENT}" + + def save_ncs_pathway(self, ncs_pathway: typing.Union[NcsPathway, dict]): + """Saves an NCS pathway object serialized to a json string + indexed by the UUID. + + :param ncs_pathway: NCS pathway object or attribute values + in a dictionary which are then serialized to a JSON string. + :type ncs_pathway: NcsPathway, dict + """ + if isinstance(ncs_pathway, NcsPathway): + ncs_pathway = ncs_pathway_to_dict(ncs_pathway) + + ncs_str = json.dumps(ncs_pathway) + + ncs_uuid = ncs_pathway[UUID_ATTRIBUTE] + ncs_root = self._get_ncs_pathway_settings_base() + + with qgis_settings(ncs_root) as settings: + settings.setValue(ncs_uuid, ncs_str) + + def get_ncs_pathway(self, + ncs_uuid: str) -> typing.Union[NcsPathway, None]: + """Gets an NCS pathway object matching the given unique identified. + + :param ncs_uuid: Unique identifier for the NCS pathway object. + :type ncs_uuid: str + + :returns: Returns the NCS pathway object matching the given + identifier else None if not found. + :rtype: NcsPathway + """ + ncs_pathway = None + + ncs_dict = self.get_ncs_pathway_dict(ncs_uuid) + if len(ncs_dict) == 0: + return None + + ncs_pathway = create_ncs_pathway(ncs_dict) + + return ncs_pathway + + def get_ncs_pathway_dict(self, ncs_uuid: str) -> dict: + """Gets an NCS pathway attribute values as a dictionary. + + :param ncs_uuid: Unique identifier for the NCS pathway object. + :type ncs_uuid: str + + :returns: Returns the NCS pathway attribute values matching the given + identifier else an empty dictionary if not found. + :rtype: dict + """ + ncs_pathway_dict = {} + + ncs_root = self._get_ncs_pathway_settings_base() + + with qgis_settings(ncs_root) as settings: + ncs_model = settings.value(ncs_uuid, dict()) + if len(ncs_model) > 0: + try: + ncs_pathway_dict = json.loads(ncs_model) + except json.JSONDecodeError: + log("NCS pathway JSON is invalid") + + return ncs_pathway_dict + + def get_all_ncs_pathways(self) -> typing.List[NcsPathway]: + """Get all the NCS pathway objects stored in settings. + + :returns: Returns all the NCS pathway objects. + :rtype: list + """ + ncs_pathways = [] + + ncs_root = self._get_ncs_pathway_settings_base() + + with qgis_settings(ncs_root) as settings: + keys = settings.childKeys() + for k in keys: + ncs_pathway = self.get_ncs_pathway(k) + if ncs_pathway is not None: + ncs_pathways.append(ncs_pathway) + + return sorted(ncs_pathways, key=lambda ncs: ncs.name) + + def update_ncs_pathways(self): + """Updates the path attribute of all NCS pathway settings + based on the BASE_DIR settings to reflect the absolute path + of each NCS pathway layer. + If BASE_DIR is empty then the NCS pathway settings will not + be updated. + """ + ncs_pathways = self.get_all_ncs_pathways() + for ncs in ncs_pathways: + self.update_ncs_pathway(ncs) + + def update_ncs_pathway(self, ncs_pathway: NcsPathway): + """Updates the attributes of the NCS pathway object + in settings. On the path, the BASE_DIR in settings + is used to reflect the absolute path of each NCS + pathway layer. If BASE_DIR is empty then the NCS + pathway setting will not be updated, this only applies + for default pathways. + + :param ncs_pathway: NCS pathway object to be updated. + :type ncs_pathway: NcsPathway + """ + base_dir = self.get_value(Settings.BASE_DIR) + if not base_dir: + return + + # Pathway location for default pathway + if not ncs_pathway.user_defined: + p = Path(ncs_pathway.path) + # Only update if path does not exist otherwise + # fallback to check under base directory. + if not p.exists(): + abs_path = f"{base_dir}/{NCS_PATHWAY_SEGMENT}/" f"{p.name}" + abs_path = str(os.path.normpath(abs_path)) + ncs_pathway.path = abs_path + + # Carbon location + abs_carbon_paths = [] + for cb_path in ncs_pathway.carbon_paths: + cp = Path(cb_path) + # Similarly, if the given carbon path does not exist then try + # to use the default one in the ncs_carbon directory. + if not cp.exists(): + abs_carbon_path = ( + f"{base_dir}/{NCS_CARBON_SEGMENT}/" f"{cp.name}" + ) + abs_carbon_path = str(os.path.normpath(abs_carbon_path)) + abs_carbon_paths.append(abs_carbon_path) + else: + abs_carbon_paths.append(cb_path) + + ncs_pathway.carbon_paths = abs_carbon_paths + + # Remove then re-insert + self.remove_ncs_pathway(str(ncs_pathway.uuid)) + self.save_ncs_pathway(ncs_pathway) + + def remove_ncs_pathway(self, ncs_uuid: str): + """Removes an NCS pathway settings entry using the UUID. + + :param ncs_uuid: Unique identifier of the NCS pathway entry + to removed. + :type ncs_uuid: str + """ + if self.get_ncs_pathway(ncs_uuid) is not None: + self.remove(f"{self.NCS_PATHWAY_BASE}/{ncs_uuid}") + + def _get_implementation_model_settings_base(self) -> str: + """Returns the path for implementation model settings. + + :returns: Base path to implementation model group. + :rtype: str + """ + return f"{self.BASE_GROUP_NAME}/" f"{self.IMPLEMENTATION_MODEL_BASE}" + + def save_implementation_model( + self, implementation_model: typing.Union[ImplementationModel, dict] + ): + """Saves an implementation model object serialized to a json string + indexed by the UUID. + + :param implementation_model: Implementation model object or attribute + values in a dictionary which are then serialized to a JSON string. + :type implementation_model: ImplementationModel, dict + """ + if isinstance(implementation_model, ImplementationModel): + priority_layers = implementation_model.priority_layers + layer_styles = implementation_model.layer_styles + style_pixel_value = implementation_model.style_pixel_value + + ncs_pathways = [] + for ncs in implementation_model.pathways: + ncs_pathways.append(str(ncs.uuid)) + + implementation_model = layer_component_to_dict( + implementation_model) + implementation_model[PRIORITY_LAYERS_SEGMENT] = priority_layers + implementation_model[PATHWAYS_ATTRIBUTE] = ncs_pathways + implementation_model[STYLE_ATTRIBUTE] = layer_styles + implementation_model[PIXEL_VALUE_ATTRIBUTE] = style_pixel_value + + if isinstance(implementation_model, dict): + priority_layers = [] + if implementation_model.get("pwls_ids") is not None: + for layer_id in implementation_model.get("pwls_ids", []): + layer = self.get_priority_layer(layer_id) + priority_layers.append(layer) + if len(priority_layers) > 0: + implementation_model[PRIORITY_LAYERS_SEGMENT] = ( + priority_layers + ) + + implementation_model_str = json.dumps(implementation_model) + + implementation_model_uuid = implementation_model[UUID_ATTRIBUTE] + implementation_model_root = ( + self._get_implementation_model_settings_base() + ) + + with qgis_settings(implementation_model_root) as settings: + settings.setValue( + implementation_model_uuid, implementation_model_str) + + def get_implementation_model( + self, implementation_model_uuid: str + ) -> typing.Union[ImplementationModel, None]: + """Gets an implementation model object matching the given unique + identified. + + :param implementation_model_uuid: Unique identifier for the + implementation model object. + :type implementation_model_uuid: str + + :returns: Returns the implementation model object matching the given + identifier else None if not found. + :rtype: ImplementationModel + """ + implementation_model = None + + implementation_model_root = ( + self._get_implementation_model_settings_base() + ) + + with qgis_settings(implementation_model_root) as settings: + implementation_model = settings.value( + implementation_model_uuid, None) + ncs_uuids = [] + if implementation_model is not None: + implementation_model_dict = {} + try: + implementation_model_dict = json.loads( + implementation_model) + except json.JSONDecodeError: + log("Implementation model JSON is invalid.") + + if PATHWAYS_ATTRIBUTE in implementation_model_dict: + ncs_uuids = implementation_model_dict[PATHWAYS_ATTRIBUTE] + + implementation_model = create_implementation_model( + implementation_model_dict + ) + if implementation_model is not None: + for ncs_uuid in ncs_uuids: + ncs = self.get_ncs_pathway(ncs_uuid) + if ncs is not None: + implementation_model.add_ncs_pathway(ncs) + + return implementation_model + + def find_implementation_model_by_name(self, name) -> typing.Dict: + """Finds an implementation model setting inside + the plugin QgsSettings that equals or matches the name. + + :param name: Implementation model name + :type name: str + + :returns: Implementation model + :rtype: ImplementationModel + """ + for model in self.get_all_implementation_models(): + model_name = model.name + trimmed_name = model_name.replace(" ", "_") + if ( + model_name == name or model_name in name or + trimmed_name in name + ): + return model + + return None + + def get_all_implementation_models( + self) -> typing.List[ImplementationModel]: + """Get all the implementation model objects stored in settings. + + :returns: Returns all the implementation model objects. + :rtype: list + """ + implementation_models = [] + + implementation_model_root = ( + self._get_implementation_model_settings_base() + ) + + with qgis_settings(implementation_model_root) as settings: + keys = settings.childKeys() + for k in keys: + implementation_model = self.get_implementation_model(k) + if implementation_model is not None: + implementation_models.append(implementation_model) + + return sorted(implementation_models, + key=lambda imp_model: imp_model.name) + + def update_implementation_model( + self, implementation_model: ImplementationModel): + """Updates the attributes of the Implementation object + in settings. On the path, the BASE_DIR in settings + is used to reflect the absolute path of each NCS + pathway layer. If BASE_DIR is empty then the NCS + pathway setting will not be updated. + + :param implementation_model: ImplementationModel object to be updated. + :type implementation_model: ImplementationModel + """ + base_dir = self.get_value(Settings.BASE_DIR) + + if base_dir: + # PWLs path update + for layer in implementation_model.priority_layers: + if layer in PRIORITY_LAYERS and base_dir not in layer.get( + PATH_ATTRIBUTE + ): + abs_pwl_path = ( + f"{base_dir}/{PRIORITY_LAYERS_SEGMENT}/" + f"{layer.get(PATH_ATTRIBUTE)}" + ) + abs_pwl_path = str(os.path.normpath(abs_pwl_path)) + layer[PATH_ATTRIBUTE] = abs_pwl_path + + # Remove then re-insert + self.remove_implementation_model(str(implementation_model.uuid)) + self.save_implementation_model(implementation_model) + + def update_implementation_models(self): + """Updates the attributes of the avaialable implementation models + + :param implementation_model: Implementation model object to be updated + :type implementation_model: ImplementationModel + """ + models = self.get_all_implementation_models() + + for implementation_model in models: + self.update_implementation_model(implementation_model) + + def remove_implementation_model(self, implementation_model_uuid: str): + """Removes an implementation model settings entry using the UUID. + + :param implementation_model_uuid: Unique identifier of the + implementation model entry to removed. + :type implementation_model_uuid: str + """ + if ( + self.get_implementation_model( + implementation_model_uuid) is not None + ): + self.remove( + f"{self.IMPLEMENTATION_MODEL_BASE}/" + f"{implementation_model_uuid}" + ) + + +settings_manager = SettingsManager() + + +class TaskConfig(object): + + scenario_name = '' + scenario_desc = '' + scenario_uuid = uuid.uuid4() + analysis_implementation_models: typing.List[ImplementationModel] = [] + priority_layers: typing.List = [] + priority_layer_groups: typing.List = [] + analysis_extent: SpatialExtent = None + snapping_enabled: bool = False + snap_layer = '' + pathway_suitability_index = 0 + carbon_coefficient = 0.0 + snap_rescale = False + snap_method = 0 + scenario: Scenario = None + + def __init__(self, scenario_name, scenario_desc, extent, + analysis_implementation_models, priority_layers, + priority_layer_groups, + snapping_enabled = False, snap_layer = '', + pathway_suitability_index = 0, + carbon_coefficient = 0.0, snap_rescale = False, + snap_method = 0, scenario_uuid = None) -> None: + self.scenario_name = scenario_name + self.scenario_desc = scenario_desc + if scenario_uuid: + self.scenario_uuid = uuid.UUID(scenario_uuid) + self.analysis_extent = SpatialExtent(bbox=extent) + self.analysis_implementation_models = analysis_implementation_models + self.priority_layers = priority_layers + self.priority_layer_groups = priority_layer_groups + self.snapping_enabled = snapping_enabled + self.snap_layer = snap_layer + self.pathway_suitability_index = pathway_suitability_index + self.carbon_coefficient = carbon_coefficient + self.snap_rescale = snap_rescale + self.snap_method = snap_method + self.scenario = Scenario( + uuid=self.scenario_uuid, + name=self.scenario_name, + description=self.scenario_desc, + extent=self.analysis_extent, + models=self.analysis_implementation_models, + weighted_models=[], + priority_layer_groups=self.priority_layer_groups + ) + + def get_implementation_model( + self, implementation_model_uuid: str + ) -> typing.Union[ImplementationModel, None]: + implementation_model = None + filtered = [ + im for im in self.analysis_implementation_models if + str(im.uuid) == implementation_model_uuid + ] + if filtered: + implementation_model = filtered[0] + return implementation_model + + def get_priority_layers(self) -> typing.List: + return self.priority_layers + + def get_priority_layer(self, identifier) -> typing.Dict: + priority_layer = None + filtered = [ + f for f in self.priority_layers if f['uuid'] == str(identifier)] + if filtered: + priority_layer = filtered[0] + return priority_layer + + def get_value(self, attr_name: Settings, default = None): + return getattr(self, attr_name.value, default) + + @classmethod + def from_dict(cls, data: dict) -> typing.Self: + config = TaskConfig( + data.get('scenario_name', ''), data.get('scenario_desc', ''), + data.get('extent', []), [], [], [] + ) + config.priority_layers = data.get('priority_layers', []) + config.priority_layer_groups = data.get('priority_layer_groups', []) + config.snapping_enabled = data.get('snapping_enabled', False) + config.snap_layer = data.get('snap_layer', '') + config.pathway_suitability_index = data.get( + 'pathway_suitability_index', 0) + config.carbon_coefficient = data.get('carbon_coefficient', 0.0) + config.snap_rescale = data.get('snap_rescale', False) + config.snap_method = data.get('snap_method', 0) + _models = data.get('implementation_models', []) + for model in _models: + uuid_str = model.get('uuid', None) + im_model = ImplementationModel( + uuid=uuid.UUID(uuid_str) if uuid_str else uuid.uuid4(), + name=model.get('name', ''), + description=model.get('description', ''), + path=model.get('path', ''), + layer_type=LayerType(model.get('layer_type', -1)), + user_defined=model.get('user_defined', False), + pathways=[], + priority_layers=model.get('priority_layers', []), + layer_styles=model.get('layer_styles', {}) + ) + pathways = model.get('pathways', []) + for pathway in pathways: + pw_uuid_str = pathway.get('uuid', None) + pathway_model = NcsPathway( + uuid=( + uuid.UUID(pw_uuid_str) if pw_uuid_str else + uuid.uuid4() + ), + name=pathway.get('name', ''), + description=pathway.get('description', ''), + path=pathway.get('path', ''), + layer_type=LayerType(pathway.get('layer_type', -1)), + carbon_paths=pathway.get('path', []) + ) + im_model.pathways.append(pathway_model) + config.analysis_implementation_models.append(im_model) + config.scenario = Scenario( + uuid=config.scenario_uuid, + name=config.scenario_name, + description=config.scenario_desc, + extent=config.analysis_extent, + models=config.analysis_implementation_models, + weighted_models=[], + priority_layer_groups=config.priority_layer_groups + ) + return config diff --git a/django_project/cplus/utils/helper.py b/django_project/cplus/utils/helper.py new file mode 100644 index 0000000..6ec0e83 --- /dev/null +++ b/django_project/cplus/utils/helper.py @@ -0,0 +1,415 @@ +import os +import uuid +import logging +from pathlib import Path +from qgis.PyQt import QtGui +from qgis.core import ( + Qgis, + QgsCoordinateReferenceSystem, + QgsCoordinateTransform, + QgsCoordinateTransformContext, + QgsDistanceArea, + QgsMessageLog, + QgsProcessingFeedback, + QgsProject, + QgsProcessing, + QgsRasterLayer, + QgsUnitTypes, +) + +from qgis.analysis import QgsAlignRaster +from qgis import processing +from cplus.definitions.defaults import TEMPLATE_NAME +from cplus.definitions.constants import ( + NCS_CARBON_SEGMENT, + NCS_PATHWAY_SEGMENT, + PRIORITY_LAYERS_SEGMENT, +) + + +logger = logging.getLogger(__name__) + + +def tr(message): + """Get the translation for a string using Qt translation API. + We implement this ourselves since we do not inherit QObject. + + :param message: String for translation. + :type message: str, QString + + :returns: Translated version of message. + :rtype: QString + """ + # noinspection PyTypeChecker,PyArgumentList,PyCallByClass + return message + + +def log( + message: str, + name: str = "qgis_cplus", + info: bool = True, + notify: bool = True, +): + """Logs the message into QGIS logs using qgis_cplus as the default + log instance. + If notify_user is True, user will be notified about the log. + + :param message: The log message + :type message: str + + :param name: Name of te log instance, qgis_cplus is the default + :type message: str + + :param info: Whether the message is about info or a + warning + :type info: bool + + :param notify: Whether to notify user about the log + :type notify: bool + """ + level = logging.INFO if info else logging.WARNING + logger.log(level, message) + level = Qgis.Info if info else Qgis.Warning + QgsMessageLog.logMessage( + message, + name, + level=level, + notifyUser=notify, + ) + + +def clean_filename(filename): + """Creates a safe filename by removing operating system + invalid filename characters. + + :param filename: File name + :type filename: str + + :returns A clean file name + :rtype str + """ + characters = " %:/,\[]<>*?" + + for character in characters: + if character in filename: + filename = filename.replace(character, "_") + + return filename + + +def calculate_raster_value_area( + layer: QgsRasterLayer, band_number: int = 1, + feedback: QgsProcessingFeedback = None +) -> dict: + """Calculates the area of value pixels for + the given band in a raster layer. + + Please note that this function will run in the main application thread + hence for best results, it is recommended to execute it + in a background process if part of a bigger workflow. + + :param layer: Input layer whose area for value pixels is to be calculated. + :type layer: QgsRasterLayer + + :param band_number: Band number to compute area, default is band one. + :type band_number: int + + :param feedback: Feedback object for progress during area calculation. + :type feedback: QgsProcessingFeedback + + :returns: A dictionary containing the pixel value as + the key and the corresponding area in hectares as the value + for all the pixels in the raster otherwise + returns a empty dictionary if the raster is invalid + or if it is empty. + :rtype: float + """ + if not layer.isValid(): + log("Invalid layer for raster area calculation.", info=False) + return {} + + algorithm_name = "native:rasterlayeruniquevaluesreport" + params = { + "INPUT": layer, + "BAND": band_number, + "OUTPUT_TABLE": "TEMPORARY_OUTPUT", + "OUTPUT_HTML_FILE": QgsProcessing.TEMPORARY_OUTPUT, + } + + algorithm_result = processing.run( + algorithm_name, params, feedback=feedback) + + # Get number of pixels with values + total_pixel_count = algorithm_result["TOTAL_PIXEL_COUNT"] + if total_pixel_count == 0: + log("Input layer for raster area calculation is empty.", info=False) + return {} + + output_table = algorithm_result["OUTPUT_TABLE"] + if output_table is None: + log("Unique values raster table could not be retrieved.", info=False) + return {} + + area_calc = QgsDistanceArea() + crs = layer.crs() + area_calc.setSourceCrs(crs, QgsCoordinateTransformContext()) + if crs is not None: + # Use ellipsoid calculation if available + area_calc.setEllipsoid(crs.ellipsoidAcronym()) + + version = Qgis.versionInt() + if version < 33000: + unit_type = QgsUnitTypes.AreaUnit.AreaHectares + else: + unit_type = Qgis.AreaUnit.Hectares + + pixel_areas = {} + features = output_table.getFeatures() + for f in features: + pixel_value = f.attribute(0) + area = f.attribute(2) + pixel_value_area = area_calc.convertAreaMeasurement(area, unit_type) + pixel_areas[pixel_value] = pixel_value_area + + return pixel_areas + + + +def transform_extent(extent, source_crs, dest_crs): + """Transforms the passed extent into the destination crs + + :param extent: Target extent + :type extent: QgsRectangle + + :param source_crs: Source CRS of the passed extent + :type source_crs: QgsCoordinateReferenceSystem + + :param dest_crs: Destination CRS + :type dest_crs: QgsCoordinateReferenceSystem + """ + + transform = QgsCoordinateTransform( + source_crs, dest_crs, QgsProject.instance()) + transformed_extent = transform.transformBoundingBox(extent) + + return transformed_extent + + +def align_rasters( + input_raster_source, + reference_raster_source, + extent=None, + output_dir=None, + rescale_values=False, + resample_method=0, +): + """ + Based from work on https://github.com/inasafe/inasafe/pull/2070 + Aligns the passed raster files source and save the results into new files. + + :param input_raster_source: Input layer source + :type input_raster_source: str + + :param reference_raster_source: Reference layer source + :type reference_raster_source: str + + :param extent: Clip extent + :type extent: list + + :param output_dir: Absolute path of the output directory for the snapped + layers + :type output_dir: str + + :param rescale_values: Whether to rescale pixel values + :type rescale_values: bool + + :param resample_method: Method to use when resampling + :type resample_method: QgsAlignRaster.ResampleAlg + + """ + + try: + snap_directory = os.path.join(output_dir, "snap_layers") + + FileUtils.create_new_dir(snap_directory) + + input_path = Path(input_raster_source) + + input_layer_output = os.path.join( + f"{snap_directory}", + f"{input_path.stem}_{str(uuid.uuid4())[:4]}.tif" + ) + + FileUtils.create_new_file(input_layer_output) + + align = QgsAlignRaster() + lst = [ + QgsAlignRaster.Item(input_raster_source, input_layer_output), + ] + + resample_method_value = QgsAlignRaster.ResampleAlg.RA_NearestNeighbour + + try: + resample_method_value = QgsAlignRaster.ResampleAlg( + int(resample_method)) + except Exception as e: + log(f"Problem creating a resample value when snapping, {e}") + + if rescale_values: + lst[0].rescaleValues = rescale_values + + lst[0].resample_method = resample_method_value + + align.setRasters(lst) + align.setParametersFromRaster(reference_raster_source) + + layer = QgsRasterLayer(input_raster_source, "input_layer") + + extent = transform_extent( + layer.extent(), + QgsCoordinateReferenceSystem(layer.crs()), + QgsCoordinateReferenceSystem(align.destinationCrs()), + ) + + align.setClipExtent(extent) + + log(f"Snapping clip extent {layer.extent().asWktPolygon()} \n") + + if not align.run(): + log( + f"Problem during snapping for {input_raster_source} and " + f"{reference_raster_source}, {align.errorMessage()}" + ) + raise Exception(align.errorMessage()) + except Exception as e: + log( + f"Problem occured when snapping, {str(e)}." + f" Update snap settings and re-run the analysis" + ) + + return None, None + + log( + f"Finished snapping" + f" original layer - {input_raster_source}," + f"snapped output - {input_layer_output} \n" + ) + + return input_layer_output, None + + +class FileUtils: + """ + Provides functionality for commonly used file-related operations. + """ + + @staticmethod + def plugin_dir() -> str: + """Returns the root directory of the plugin. + + :returns: Root directory of the plugin. + :rtype: str + """ + return os.path.join(os.path.dirname(os.path.realpath(__file__))) + + @staticmethod + def get_icon(file_name: str) -> QtGui.QIcon: + """Creates an icon based on the icon name in the 'icons' folder. + + :param file_name: File name which should include the extension. + :type file_name: str + + :returns: Icon object matching the file name. + :rtype: QtGui.QIcon + """ + icon_path = os.path.normpath( + f"{FileUtils.plugin_dir()}/icons/{file_name}") + + if not os.path.exists(icon_path): + return QtGui.QIcon() + + return QtGui.QIcon(icon_path) + + @staticmethod + def report_template_path(file_name=None) -> str: + """Get the absolute path to the template file with the given name. + Caller needs to verify that the file actually exists. + + :param file_name: Template file name including the extension. If + none is specified then it will use `main.qpt` as the default + template name. + :type file_name: str + + :returns: The absolute path to the template file with the given name. + :rtype: str + """ + if file_name is None: + file_name = TEMPLATE_NAME + + absolute_path = f"{FileUtils.plugin_dir()}/data/reports/{file_name}" + + return os.path.normpath(absolute_path) + + @staticmethod + def create_ncs_pathways_dir(base_dir: str): + """Creates an NCS subdirectory under BASE_DIR. Skips + creation of the subdirectory if it already exists. + """ + if not Path(base_dir).is_dir(): + return + + ncs_pathway_dir = f"{base_dir}/{NCS_PATHWAY_SEGMENT}" + message = ( + "Missing parent directory when " + "creating NCS pathways subdirectory." + ) + FileUtils.create_new_dir(ncs_pathway_dir, message) + + @staticmethod + def create_ncs_carbon_dir(base_dir: str): + """Creates an NCS subdirectory for carbon layers under BASE_DIR. + Skips creation of the subdirectory if it already exists. + """ + if not Path(base_dir).is_dir(): + return + + ncs_carbon_dir = f"{base_dir}/{NCS_CARBON_SEGMENT}" + message = ( + "Missing parent directory when creating NCS carbon subdirectory." + ) + FileUtils.create_new_dir(ncs_carbon_dir, message) + + def create_pwls_dir(base_dir: str): + """Creates priority weighting layers subdirectory under BASE_DIR. + Skips creation of the subdirectory if it already exists. + """ + if not Path(base_dir).is_dir(): + return + + pwl_dir = f"{base_dir}/{PRIORITY_LAYERS_SEGMENT}" + message = ( + "Missing parent directory when creating " + "priority weighting layers subdirectory." + ) + FileUtils.create_new_dir(pwl_dir, message) + + @staticmethod + def create_new_dir(directory: str, log_message: str = ""): + """Creates new file directory if it doesn't exist""" + p = Path(directory) + if not p.exists(): + try: + p.mkdir() + except (FileNotFoundError, OSError): + log(log_message) + + @staticmethod + def create_new_file(file_path: str, log_message: str = ""): + """Creates new file""" + p = Path(file_path) + + if not p.exists(): + try: + p.touch(exist_ok=True) + except FileNotFoundError: + log(log_message) diff --git a/django_project/cplus/views.py b/django_project/cplus/views.py index 91ea44a..60f00ef 100644 --- a/django_project/cplus/views.py +++ b/django_project/cplus/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/django_project/cplus_api/admin.py b/django_project/cplus_api/admin.py index 8c38f3f..846f6b4 100644 --- a/django_project/cplus_api/admin.py +++ b/django_project/cplus_api/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - # Register your models here. diff --git a/django_project/cplus_api/api_views/user.py b/django_project/cplus_api/api_views/user.py index 9086e6c..d581baf 100644 --- a/django_project/cplus_api/api_views/user.py +++ b/django_project/cplus_api/api_views/user.py @@ -11,4 +11,3 @@ def get(self, request, *args, **kwargs): return Response(status=200, data={ 'detail': 'OK' }) - diff --git a/django_project/cplus_api/models.py b/django_project/cplus_api/models.py index 71a8362..6b20219 100644 --- a/django_project/cplus_api/models.py +++ b/django_project/cplus_api/models.py @@ -1,3 +1 @@ -from django.db import models - # Create your models here. diff --git a/django_project/cplus_api/tasks/__init__.py b/django_project/cplus_api/tasks/__init__.py index 555067d..7b65095 100644 --- a/django_project/cplus_api/tasks/__init__.py +++ b/django_project/cplus_api/tasks/__init__.py @@ -1 +1,2 @@ from .cleaner import * # noqa +from .runner import * # noqa diff --git a/django_project/cplus_api/tasks/runner.py b/django_project/cplus_api/tasks/runner.py new file mode 100644 index 0000000..d4a66ba --- /dev/null +++ b/django_project/cplus_api/tasks/runner.py @@ -0,0 +1,43 @@ +"""Task to run scneario analysis.""" + +from celery import shared_task +import logging +import time +import json + +logger = logging.getLogger(__name__) + + +def run_dummy_task(): + from cplus.tasks.analysis import ScenarioAnalysisTask + from cplus.utils.conf import TaskConfig + with open('/home/web/media/input.json', 'r') as fp: + task_dict = json.load(fp) + task_config = TaskConfig.from_dict(task_dict) + analysis_task = ScenarioAnalysisTask(task_config) + + analysis_task.run() + + +@shared_task(name="run_scenario_analysis_task") +def run_scenario_analysis_task(): + logger.info('Triggered run_scenario_analysis_task') + start_time = time.time() + from qgis.core import QgsApplication + # Supply path to qgis install location + QgsApplication.setPrefixPath("/usr/bin/qgis", True) + # Create a reference to the QgsApplication. Setting the + # second argument to False disables the GUI. + qgs = QgsApplication([], False) + # Load providers + qgs.initQgis() + # init processing plugins + import processing # noqa + from processing.core.Processing import Processing + Processing.initialize() + print("Success!") + run_dummy_task() + print(f'execution time: {time.time() - start_time} seconds') + # use qgs.exit() if worker can be reused to execute another task + # qgs.exit() + qgs.exitQgis() diff --git a/django_project/cplus_api/views.py b/django_project/cplus_api/views.py index 91ea44a..60f00ef 100644 --- a/django_project/cplus_api/views.py +++ b/django_project/cplus_api/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/django_project/worker_entrypoint.sh b/django_project/worker_entrypoint.sh index dc414e3..69b5c4f 100755 --- a/django_project/worker_entrypoint.sh +++ b/django_project/worker_entrypoint.sh @@ -23,7 +23,6 @@ CPLUS_C=${CPLUS_QUEUE_CONCURRENCY:-1} # start tile and validate workers celery -A core multi start cplus -c:cplus $CPLUS_C -Q:cplus cplus -l INFO --logfile=/proc/1/fd/1 -# celery -A core multi start cplus -c:cplus $CPLUS_C -Q:cplus cplus -l INFO # start default worker celery -A core worker -l INFO --logfile=/proc/1/fd/1