From 21786d51671371080070755f85b4ee00e2344d4f Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 2 Dec 2024 10:23:53 +0300 Subject: [PATCH 01/25] Incorporate pathway type in NCS editor including save and load option. --- src/cplus_plugin/definitions/constants.py | 1 + .../gui/ncs_pathway_editor_dialog.py | 33 +++- src/cplus_plugin/models/base.py | 30 ++++ src/cplus_plugin/models/helpers.py | 9 + .../ui/ncs_pathway_editor_dialog.ui | 162 +++++++++++------- 5 files changed, 174 insertions(+), 61 deletions(-) diff --git a/src/cplus_plugin/definitions/constants.py b/src/cplus_plugin/definitions/constants.py index f15b7119d..7379a3d70 100644 --- a/src/cplus_plugin/definitions/constants.py +++ b/src/cplus_plugin/definitions/constants.py @@ -32,6 +32,7 @@ NAME_ATTRIBUTE = "name" PATH_ATTRIBUTE = "path" PATHWAYS_ATTRIBUTE = "pathways" +PATHWAY_TYPE_ATTRIBUTE = "pathway_type" PIXEL_VALUE_ATTRIBUTE = "style_pixel_value" STYLE_ATTRIBUTE = "style" USER_DEFINED_ATTRIBUTE = "user_defined" diff --git a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py index 83a01eac8..2483305b5 100644 --- a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py +++ b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py @@ -16,7 +16,7 @@ from .carbon_item_model import CarbonLayerItem, CarbonLayerModel from ..conf import Settings, settings_manager from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE -from ..models.base import LayerType, NcsPathway +from ..models.base import LayerType, NcsPathway, NcsPathwayType from ..utils import FileUtils, open_documentation, tr, log WidgetUi, _ = loadUiType( @@ -87,6 +87,17 @@ def __init__(self, parent=None, ncs_pathway=None, excluded_names=None): ) self.btn_add_default_carbon.menu().addAction(action) + self._pathway_type_group = QtWidgets.QButtonGroup(self) + self._pathway_type_group.addButton( + self.rb_protection, NcsPathwayType.PROTECTION.value + ) + self._pathway_type_group.addButton( + self.rb_restoration, NcsPathwayType.RESTORATION.value + ) + self._pathway_type_group.addButton( + self.rb_management, NcsPathwayType.MANAGEMENT.value + ) + self._excluded_names = excluded_names if excluded_names is None: self._excluded_names = [] @@ -148,6 +159,13 @@ def _update_controls(self): self.txt_name.setText(self._ncs_pathway.name) self.txt_description.setPlainText(self._ncs_pathway.description) + if self._ncs_pathway.pathway_type == NcsPathwayType.PROTECTION: + self.rb_protection.setChecked(True) + if self._ncs_pathway.pathway_type == NcsPathwayType.RESTORATION: + self.rb_restoration.setChecked(True) + if self._ncs_pathway.pathway_type == NcsPathwayType.MANAGEMENT: + self.rb_management.setChecked(True) + if self._layer: layer_path = self._layer.source() self._add_layer_path(layer_path) @@ -210,6 +228,11 @@ def validate(self) -> bool: self._show_warning_message(msg) status = False + if self._pathway_type_group.checkedId() == -1: + msg = tr("The NCS pathway type is not specified.") + self._show_warning_message(msg) + status = False + layer = self._get_selected_map_layer() default_layer = self._get_selected_default_layer() if layer is None and default_layer is None: @@ -242,6 +265,14 @@ def _create_update_ncs_pathway(self): self._ncs_pathway.name = self.txt_name.text() self._ncs_pathway.description = self.txt_description.toPlainText() + selected_pathway_type_id = self._pathway_type_group.checkedId() + if selected_pathway_type_id == NcsPathwayType.PROTECTION.value: + self._ncs_pathway.pathway_type = NcsPathwayType.PROTECTION + elif selected_pathway_type_id == NcsPathwayType.RESTORATION.value: + self._ncs_pathway.pathway_type = NcsPathwayType.RESTORATION + elif selected_pathway_type_id == NcsPathwayType.MANAGEMENT.value: + self._ncs_pathway.pathway_type = NcsPathwayType.MANAGEMENT + self._ncs_pathway.layer_type = LayerType.RASTER default_layer = self._get_selected_default_layer() diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index c00876d27..605ae3298 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -277,11 +277,41 @@ def is_default_layer(self) -> bool: return self.layer_uuid is not None +class NcsPathwayType(IntEnum): + """Type of NCS pathway.""" + + PROTECTION = 0 + RESTORATION = 1 + MANAGEMENT = 2 + UNDEFINED = 99 + + @staticmethod + def from_int(int_enum: int) -> "NcsPathwayType": + """Creates an enum from the corresponding int equivalent. + + :param int_enum: Integer representing the NCS pathway type. + :type int_enum: int + + :returns: NCS pathway type enum corresponding to the given + integer else unknown if not found. + :rtype: NcsPathwayType + """ + if int_enum == 0: + return NcsPathwayType.PROTECTION + elif int_enum == 1: + return NcsPathwayType.RESTORATION + elif int_enum == 2: + return NcsPathwayType.MANAGEMENT + else: + return NcsPathwayType.UNDEFINED + + @dataclasses.dataclass class NcsPathway(LayerModelComponent): """Contains information about an NCS pathway layer.""" carbon_paths: typing.List[str] = dataclasses.field(default_factory=list) + pathway_type: NcsPathwayType = NcsPathwayType.UNDEFINED def __eq__(self, other: "NcsPathway") -> bool: """Test equality of NcsPathway object with another diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index 5a1a4f4aa..c7d51975d 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -24,6 +24,7 @@ LayerModelComponentType, LayerType, NcsPathway, + NcsPathwayType, ScenarioResult, SpatialExtent, ) @@ -56,6 +57,7 @@ NUMBER_FORMATTER_ID_ATTRIBUTE, NUMBER_FORMATTER_PROPS_ATTRIBUTE, PATH_ATTRIBUTE, + PATHWAY_TYPE_ATTRIBUTE, PIXEL_VALUE_ATTRIBUTE, PRIORITY_LAYERS_SEGMENT, REMOVE_EXISTING_ATTRIBUTE, @@ -196,6 +198,12 @@ def create_ncs_pathway(source_dict) -> typing.Union[NcsPathway, None]: if CARBON_PATHS_ATTRIBUTE in source_dict: ncs.carbon_paths = source_dict[CARBON_PATHS_ATTRIBUTE] + if PATHWAY_TYPE_ATTRIBUTE in source_dict: + ncs.pathway_type = NcsPathwayType.from_int(source_dict[PATHWAY_TYPE_ATTRIBUTE]) + else: + # Assign undefined + ncs.pathway_type = NcsPathwayType.UNDEFINED + return ncs @@ -286,6 +294,7 @@ def ncs_pathway_to_dict(ncs_pathway: NcsPathway, uuid_to_str=True) -> dict: """ base_ncs_dict = layer_component_to_dict(ncs_pathway, uuid_to_str) base_ncs_dict[CARBON_PATHS_ATTRIBUTE] = ncs_pathway.carbon_paths + base_ncs_dict[PATHWAY_TYPE_ATTRIBUTE] = ncs_pathway.pathway_type return base_ncs_dict diff --git a/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui b/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui index 79230ed4b..6c581863e 100644 --- a/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui +++ b/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui @@ -10,34 +10,17 @@ 577 + + + 0 + 0 + + NCS Pathway Editor - - - - - - - - - - - - - Help - - - - - - - 200 - - - - + Carbon layers @@ -118,33 +101,6 @@ - - - - Name - - - - - - - Map layer - - - - - - - Qt::Horizontal - - - - 178 - 20 - - - - @@ -225,14 +181,48 @@ - + + + + ... + + + + + + + Online Defaults - + + + + Help + + + + + + + + + + Map layer + + + + + + + 200 + + + + Qt::Horizontal @@ -242,23 +232,75 @@ - - + + - ... + Description - - + + + + + + + Qt::Horizontal + + + + 178 + 20 + + + + + + - Description + Name - + + + + + + 0 + 0 + + + + Pathway type + + + + + + Protection + + + + + + + Restoration + + + + + + + Management + + + + + + From 0abcc9d3ab52316861b5e60609f6181eef7db8e8 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 3 Dec 2024 17:09:14 +0300 Subject: [PATCH 02/25] Incorporate settings for managing IC dataset. --- src/cplus_plugin/conf.py | 8 + .../gui/settings/cplus_options.py | 106 ++++++++++++- src/cplus_plugin/models/base.py | 27 ++++ src/cplus_plugin/ui/cplus_settings.ui | 143 +++++++++++++++++- 4 files changed, 279 insertions(+), 5 deletions(-) diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 6a5bb9c15..1706aef1c 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -251,6 +251,14 @@ class Settings(enum.Enum): ACTIVE_ONLINE_TASK = "active_online_task" + # Irrecoverable carbon + IRRECOVERABLE_CARBON_SOURCE_TYPE = "carbon/irrecoverable_carbon_source_type" + IRRECOVERABLE_CARBON_SOURCE = "carbon/irrecoverable_carbon_source" + IRRECOVERABLE_CARBON_ENABLED = "carbon/irrecoverable_carbon_enabled" + IRRECOVERABLE_CARBON_ONLINE_LOCAL_DIR = ( + "carbon/irrecoverable_carbon_online_local_dir" + ) + class SettingsManager(QtCore.QObject): """Manages saving/loading settings for the plugin in QgsSettings.""" diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 2f4bfbe7f..5410423cf 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -31,6 +31,7 @@ settings_manager, Settings, ) +from ...models.base import DataSourceType from ...definitions.constants import CPLUS_OPTIONS_KEY from ...definitions.defaults import ( GENERAL_OPTIONS_TITLE, @@ -508,6 +509,33 @@ def __init__(self, parent=None, main_widget=None) -> None: self.pushButton_forgot_pwd.clicked.connect(self.forgot_pwd) self.pushButton_delete_user.clicked.connect(self.delete) + # Irrecoverable carbon + self.gb_ic_reference_layer.toggled.connect( + self._on_irrecoverable_group_box_toggled + ) + + self._irrecoverable_group = QtWidgets.QButtonGroup(self) + self._irrecoverable_group.addButton(self.rb_local, DataSourceType.LOCAL.value) + self._irrecoverable_group.addButton(self.rb_online, DataSourceType.ONLINE.value) + self._irrecoverable_group.idToggled.connect( + self._on_irrecoverable_button_group_toggled + ) + + self.fw_irrecoverable_carbon.setDialogTitle( + tr("Select Irrecoverable Carbon Dataset") + ) + self.fw_irrecoverable_carbon.setRelativeStorage( + QgsFileWidget.RelativeStorage.Absolute + ) + self.fw_irrecoverable_carbon.setStorageMode(QgsFileWidget.StorageMode.GetFile) + + self.cbo_irrecoverable_carbon.layerChanged.connect( + self._on_irrecoverable_carbon_layer_changed + ) + + self.lbl_url_tip.setPixmap(FileUtils.get_pixmap("info_green.svg")) + self.lbl_url_tip.setScaledContents(True) + # Load gui default value from settings auth_id = settings.value("cplusplugin/auth") if auth_id is not None: @@ -645,6 +673,25 @@ def save_settings(self) -> None: "CPLUS - Base directory not found: ", base_dir_path ) + # Irrecoverable carbon + if self.rb_local.isChecked(): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE, + self.fw_irrecoverable_carbon.filePath(), + ) + elif self.rb_online.isChecked(): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value + ) + + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ENABLED, + self.gb_ic_reference_layer.isChecked(), + ) + def load_settings(self) -> None: """Loads the settings and displays it in the options UI""" # Advanced settings @@ -713,6 +760,27 @@ def load_settings(self) -> None: if len(mask_paths_list) > 0: self.mask_layers_changed() + # Irrecoverable carbon + irrecoverable_carbon_enabled = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ENABLED, default=False + ) + if irrecoverable_carbon_enabled: + self.gb_ic_reference_layer.setChecked(True) + + self.fw_irrecoverable_carbon.setFilePath( + settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_SOURCE, default="") + ) + + source_type_int = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, + default=DataSourceType.UNDEFINED.value, + setting_type=int, + ) + if source_type_int == DataSourceType.LOCAL.value: + self.sw_irrecoverable_carbon.setCurrentIndex(0) + elif source_type_int == DataSourceType.ONLINE.value: + self.sw_irrecoverable_carbon.setCurrentIndex(1) + def showEvent(self, event: QShowEvent) -> None: """Show event being called. This will display the plugin settings. The stored/saved settings will be loaded. @@ -733,6 +801,42 @@ def closeEvent(self, event: QShowEvent) -> None: super().closeEvent(event) + def _on_irrecoverable_button_group_toggled(self, button_id: int, toggled: bool): + """Slot raised when a button in the irrecoverable + button group has been toggled. + + :param button_id: Button identifier. + :type button_id: int + + :param toggled: True if the button is checked else False + if unchecked. + :type toggled: bool + """ + if button_id == DataSourceType.LOCAL.value and toggled: + self.sw_irrecoverable_carbon.setCurrentIndex(0) + elif button_id == DataSourceType.ONLINE.value and toggled: + self.sw_irrecoverable_carbon.setCurrentIndex(1) + + def _on_irrecoverable_group_box_toggled(self, toggled: bool): + """Slot raised when the irrecoverable group box has + been toggled. + + :param toggled: True if the button is checked else + False if unchecked. + :type toggled: bool + """ + settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_ENABLED, toggled) + + def _on_irrecoverable_carbon_layer_changed(self, layer: qgis.core.QgsMapLayer): + """Sets the file path of the currently selected irrecoverable + layer in the corresponding file input widget. + + :param layer: Currently selected layer. + :type layer: QgsMapLayer + """ + if layer is not None: + self.fw_irrecoverable_carbon.setFilePath(layer.source()) + def _on_add_mask_layer(self, activated: bool): """Slot raised to add a mask layer.""" data_dir = settings_manager.get_value(Settings.LAST_MASK_DIR, default=None) @@ -806,7 +910,7 @@ def _show_mask_path_selector(self, layer_dir: str) -> str: layer_path, _ = QFileDialog.getOpenFileName( self, - self.tr("Select mask Layer"), + self.tr("Select Mask Layer"), layer_dir, f"{filter_tr} (*.*)", options=QFileDialog.DontResolveSymlinks, diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index 605ae3298..4eeaf623a 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -5,6 +5,7 @@ import dataclasses import datetime +import enum from enum import Enum, IntEnum import os.path import typing @@ -693,3 +694,29 @@ class ScenarioResult: analysis_output: typing.Dict = None output_layer_name: str = "" scenario_directory: str = "" + + +class DataSourceType(IntEnum): + """Specifies whether a data source is from a local or online source.""" + + LOCAL = 0 + ONLINE = 1 + UNDEFINED = -1 + + @staticmethod + def from_int(int_enum: int) -> "DataSourceType": + """Creates an enum from the corresponding int equivalent. + + :param int_enum: Integer representing the data source type. + :type int_enum: int + + :returns: Data source type enum corresponding to the given + integer else unknown if not found. + :rtype: DataSourceType + """ + if int_enum == 0: + return DataSourceType.LOCAL + elif int_enum == 1: + return DataSourceType.ONLINE + else: + return DataSourceType.UNDEFINED diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index b7a6a8af8..ae0a964bc 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -10,7 +10,7 @@ 0 0 594 - 719 + 754 @@ -36,8 +36,8 @@ 0 0 - 558 - 732 + 555 + 737 @@ -76,7 +76,7 @@ - Scenario Analysis + Scenario analysis false @@ -309,6 +309,141 @@ + + + + + 0 + 0 + + + + Irrecoverable carbon reference layer + + + true + + + true + + + + + + Local path + + + true + + + + + + + Online source + + + + + + + 1 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + + + + + + + + 3 + + + 3 + + + 3 + + + + + URL + + + + + + + Specify the URL to fetch the dataset in the CI server + + + + + + + + 0 + 0 + + + + + 24 + 24 + + + + + 24 + 24 + + + + The scenario extent defined in Step 1 will be used to clip the reference layer to be downloaded. The download process will start in the background on applying the settings. + + + QFrame::NoFrame + + + + + + + + + + Download directory + + + + + + + + + + + + + From 4ca71540fdfbb076c9c6e0532324c7e37e47de3f Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 3 Dec 2024 17:36:35 +0300 Subject: [PATCH 03/25] Incorporate validity checks for IC dataset. --- .../gui/settings/cplus_options.py | 32 +++++++++++++++++++ src/cplus_plugin/ui/cplus_settings.ui | 6 ++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 5410423cf..0133ebf14 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -781,6 +781,38 @@ def load_settings(self) -> None: elif source_type_int == DataSourceType.ONLINE.value: self.sw_irrecoverable_carbon.setCurrentIndex(1) + self.validate_current_irrecoverable_data_source() + + def validate_current_irrecoverable_data_source(self): + """Checks if the currently selected irrecoverable data source + is valid. + """ + self.message_bar.clearWidgets() + if self.rb_local.isChecked(): + local_path = self.fw_irrecoverable_carbon.filePath() + if not os.path.exists(local_path): + tr_msg = tr("CPLUS - Local irrecoverable carbon dataset not found") + self.message_bar.pushWarning(tr_msg, local_path) + elif self.rb_online.isChecked(): + dataset_url = self.txt_ic_url.text() + if not dataset_url: + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined") + ) + + url_checker = QtCore.QUrl(dataset_url, QtCore.QUrl.StrictMode) + if url_checker.isLocalFile(): + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), + tr("Invalid URL referencing a local file"), + ) + else: + if not url_checker.isValid(): + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), + tr("URL is invalid."), + ) + def showEvent(self, event: QShowEvent) -> None: """Show event being called. This will display the plugin settings. The stored/saved settings will be loaded. diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index ae0a964bc..ff4907467 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -390,7 +390,7 @@ - + Specify the URL to fetch the dataset in the CI server @@ -430,12 +430,12 @@ - Download directory + File name - + From 0b9c4b834c8904b229caf0b1994553d03332c77e Mon Sep 17 00:00:00 2001 From: Kahiu Date: Fri, 6 Dec 2024 15:19:43 +0300 Subject: [PATCH 04/25] Update IC settings. --- .../gui/settings/cplus_options.py | 1 + src/cplus_plugin/ui/cplus_settings.ui | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 0133ebf14..70da6460b 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -788,6 +788,7 @@ def validate_current_irrecoverable_data_source(self): is valid. """ self.message_bar.clearWidgets() + if self.rb_local.isChecked(): local_path = self.fw_irrecoverable_carbon.filePath() if not os.path.exists(local_path): diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index ff4907467..7824edea4 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -36,8 +36,8 @@ 0 0 - 555 - 737 + 572 + 732 @@ -329,6 +329,9 @@ + + Specify a dataset in the local computer or network drive + Local path @@ -339,6 +342,9 @@ + + Specify the URL for the fetching the dataset from an online server + Online source @@ -374,13 +380,16 @@ - 3 + 2 - 3 + 2 - 3 + 2 + + + 2 From 43a4eb7740f293074e2b8f476c5a9d7d5a9a8ed3 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 7 Dec 2024 08:21:33 +0300 Subject: [PATCH 05/25] Add function for computing total IC from mean layer. --- src/cplus_plugin/conf.py | 6 +- .../gui/settings/cplus_options.py | 8 +- src/cplus_plugin/lib/carbon.py | 220 ++++++++++++++++++ 3 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 src/cplus_plugin/lib/carbon.py diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 1706aef1c..88047f113 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -253,9 +253,11 @@ class Settings(enum.Enum): # Irrecoverable carbon IRRECOVERABLE_CARBON_SOURCE_TYPE = "carbon/irrecoverable_carbon_source_type" - IRRECOVERABLE_CARBON_SOURCE = "carbon/irrecoverable_carbon_source" + # Path for local data source + IRRECOVERABLE_CARBON_LOCAL_SOURCE = "carbon/irrecoverable_carbon_source" IRRECOVERABLE_CARBON_ENABLED = "carbon/irrecoverable_carbon_enabled" - IRRECOVERABLE_CARBON_ONLINE_LOCAL_DIR = ( + # Path where the online data source will be saved locally + IRRECOVERABLE_CARBON_ONLINE_LOCAL_SOURCE = ( "carbon/irrecoverable_carbon_online_local_dir" ) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 70da6460b..cda9afd61 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -679,7 +679,7 @@ def save_settings(self) -> None: Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value ) settings_manager.set_value( - Settings.IRRECOVERABLE_CARBON_SOURCE, + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, self.fw_irrecoverable_carbon.filePath(), ) elif self.rb_online.isChecked(): @@ -766,9 +766,13 @@ def load_settings(self) -> None: ) if irrecoverable_carbon_enabled: self.gb_ic_reference_layer.setChecked(True) + else: + self.gb_ic_reference_layer.setChecked(False) self.fw_irrecoverable_carbon.setFilePath( - settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_SOURCE, default="") + settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, default="" + ) ) source_type_int = settings_manager.get_value( diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py new file mode 100644 index 000000000..5dff4fc46 --- /dev/null +++ b/src/cplus_plugin/lib/carbon.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +""" +Contains functions for carbon calculations. +""" + +from functools import partial +import math +import os +import typing +from itertools import count + +from qgis.core import ( + QgsFeedback, + QgsProcessingContext, + QgsProcessingException, + QgsProcessingFeedback, + QgsProcessingMultiStepFeedback, + QgsRasterIterator, + QgsRasterLayer, + QgsRectangle, +) +from qgis import processing + +from qgis.PyQt import QtCore + +from ..definitions.constants import NPV_PRIORITY_LAYERS_SEGMENT, PRIORITY_LAYERS_SEGMENT +from ..conf import settings_manager, Settings +from ..models.base import DataSourceType +from ..utils import clean_filename, FileUtils, log, tr + + +# For now, will set this manually but for future implementation, consider +# calculating this automatically based on the pixel size and CRS of the +# reference layer. This area is in hectares i.e. 300m by 300m pixel size. +MEAN_REFERENCE_LAYER_AREA = 9.0 + + +def calculate_irrecoverable_carbon_from_mean( + ncs_pathways_layer: QgsRasterLayer, +) -> float: + """Calculates the total irrecoverable carbon for protected NCS pathways + using the reference layer defined in settings that is based on the + mean value per hectare. + + This is a manual, pixel-by-pixel analysis that overcomes the limitations + of the raster calculator and zonal statistics tools, which use the intersection + of the center point of the reference pixel to determine whether the reference + pixel will be considered in the computation. The use of these tools results in some + valid intersecting pixels being excluded from the analysis. This is a known + issue that has been raised in the QGIS GitHub repo, hence the reason of + adopting this function. + + :param ncs_pathways_layer: Layer containing a union of protected NCS pathways. + The CRS needs to be WGS84 otherwise the result will be incorrect. + :type ncs_pathways_layer: QgsRasterLayer + + :returns: The total irrecoverable carbon for protected NCS pathways + specified in the input. If there are any errors during the operation, + such as an invalid input raster layer, then -1.0 will be returned. + :rtype: float + """ + if not ncs_pathways_layer.isValid(): + log("Input union of protected NCS pathways is invalid.", info=False) + return -1.0 + + # TBC - should we cancel the process if using IC is disabled in settings? If + # so, we will need to explicitly need to check the corresponding settings. + source_type_int = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, + default=DataSourceType.UNDEFINED.value, + setting_type=int, + ) + reference_source_path = "" + if source_type_int == DataSourceType.LOCAL.value: + reference_source_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, default="" + ) + elif source_type_int == DataSourceType.ONLINE.value: + reference_source_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_SOURCE, default="" + ) + + if not reference_source_path: + log( + "Data source for reference irrecoverable carbon layer not found.", + info=False, + ) + return -1.0 + + norm_source_path = os.path.normpath(reference_source_path) + if not os.path.exists(norm_source_path): + error_msg = ( + f"Data source for reference irrecoverable carbon layer " + f"{norm_source_path} does not exist." + ) + log(error_msg, info=False) + return -1.0 + + reference_irrecoverable_carbon_layer = QgsRasterLayer( + norm_source_path, "mean_irrecoverable_carbon" + ) + + # Check CRS and warn if different + if reference_irrecoverable_carbon_layer.crs() != ncs_pathways_layer.crs(): + log( + "Final computation might be incorrect as protected NCS " + "pathways and reference irrecoverable carbon layer have different " + "CRSs.", + info=False, + ) + + reference_extent = reference_irrecoverable_carbon_layer.extent() + ncs_pathways_extent = ncs_pathways_layer.extent() + # if they do not intersect then exit. This might also be related to the CRS. + if not reference_extent.intersects(ncs_pathways_extent): + log( + "The protected NCS pathways layer does not intersect with " + "the reference irrecoverable carbon layer.", + info=False, + ) + return -1.0 + + reference_provider = reference_irrecoverable_carbon_layer.dataProvider() + reference_layer_iterator = QgsRasterIterator(reference_provider) + reference_layer_iterator.startRasterRead( + 1, reference_provider.xSize(), reference_provider.ySize(), reference_extent + ) + + irrecoverable_carbon_intersecting_pixel_values = [] + + while True: + ( + success, + columns, + rows, + block, + left, + top, + ) = reference_layer_iterator.readNextRasterPart(1) + + if not success: + log( + "Unable to read the reference irrecoverable carbon layer.", + info=False, + ) + break + + for r in range(rows): + block_part_y_min = reference_extent.yMaximum() - ( + (r + 1) / rows * reference_extent.height() + ) + block_part_y_max = reference_extent.yMaximum() - ( + r / rows * reference_extent.height() + ) + + for c in range(columns): + if block.isNoData(r, c): + continue + + block_part_x_min = reference_extent.xMinimum() + ( + c / columns * reference_extent.width() + ) + block_part_x_max = reference_extent.xMinimum() + ( + (c + 1) / columns * reference_extent.width() + ) + + # Use this to check if there are intersecting NCS pathway pixels in + # the reference layer block + analysis_extent = QgsRectangle( + block_part_x_min, + block_part_y_min, + block_part_x_max, + block_part_y_max, + ) + ncs_cols = math.ceil( + 1.0 + * analysis_extent.width() + / ncs_pathways_layer.rasterUnitsPerPixelX() + ) + ncs_rows = math.ceil( + 1.0 + * analysis_extent.height() + / ncs_pathways_layer.rasterUnitsPerPixelY() + ) + + ncs_block = ncs_pathways_layer.dataProvider().block( + 1, analysis_extent, ncs_cols, ncs_rows + ) + ncs_block_data = ncs_block.data() + + fill_data = QtCore.QByteArray() + if ncs_pathways_layer.dataProvider().sourceHasNoDataValue(1): + # If there are no overlaps, the block will contain nodata values + fill_data = ncs_block.valueBytes( + ncs_block.dataType(), ncs_block.noDataValue() + ) + + # Check if the NCS block within the reference block contains + # any other values apart from nodata. + ncs_ba_set = set(ncs_block_data[i] for i in range(ncs_block_data.size())) + fill_ba_set = set(fill_data[i] for i in range(fill_data.size())) + + if ncs_ba_set - fill_ba_set: + # we have valid overlapping pixels hence we can pick the value of + # the reference IC layer. + irrecoverable_carbon_intersecting_pixel_values.append(block.value(r, c)) + + reference_layer_iterator.stopRasterRead(1) + + ic_count = len(irrecoverable_carbon_intersecting_pixel_values) + if count == 0: + log( + "No protected NCS pathways were found in the reference layer.", + info=False, + ) + return -1.0 + + ic_mean = sum(irrecoverable_carbon_intersecting_pixel_values) / float(ic_count) + + return MEAN_REFERENCE_LAYER_AREA * ic_count * ic_mean From 4cbefd46487a5b45ddb0d0b1af8acef298457f21 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 9 Dec 2024 03:19:00 +0300 Subject: [PATCH 06/25] Add expression function for calculating IC of activity. --- src/cplus_plugin/definitions/defaults.py | 29 +++++++ src/cplus_plugin/lib/carbon.py | 98 +++++++++++++++++++----- src/cplus_plugin/lib/reports/metrics.py | 76 +++++++++++++++++- 3 files changed, 182 insertions(+), 21 deletions(-) diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index cd2dea934..de26ef3c6 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -243,3 +243,32 @@ DEFAULT_BASE_COMPARISON_REPORT_NAME = "Scenario Comparison Report" MAXIMUM_COMPARISON_REPORTS = 10 + +NPV_EXPRESSION_DESCRIPTION = ( + "Calculates the financial NPV of the current " + "activity. This returns the equivalent of the " + "area of the current activity (in hectares) " + "and multiplies it by the NPV rate (US$/ha) " + "for the activity calculated via the NPV PWL " + "Manager.
NOTE: If the NPV is not defined " + "then the function will return -1.0." +) +PWL_IMPACT_EXPRESSION_DESCRIPTION = ( + "Calculates the impact of the " + "current activity by multiplying " + "the area of the activity (in hectares) " + "by a user-defined number of jobs created per " + "hectare. The activity area will be " + "automatically populated during the computation." +) +MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION = ( + "Calculates the total irrecoverable carbon (tons C) of " + "protection NCS pathways in an activity using the mean " + "reference irrecoverable carbon dataset. This dataset " + "needs to be defined in the CPLUS settings for this " + "expression to be evaluated.
NOTE: A value of -1.0 " + "will be returned if an error is encountered, or 0.0 if " + "there are no protected NCS pathways in the activity or " + "no overlapping pixels with the reference layer in the " + "area of interest." +) diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 5dff4fc46..39a1dd010 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -15,6 +15,7 @@ QgsProcessingException, QgsProcessingFeedback, QgsProcessingMultiStepFeedback, + QgsRasterBlock, QgsRasterIterator, QgsRasterLayer, QgsRectangle, @@ -25,7 +26,7 @@ from ..definitions.constants import NPV_PRIORITY_LAYERS_SEGMENT, PRIORITY_LAYERS_SEGMENT from ..conf import settings_manager, Settings -from ..models.base import DataSourceType +from ..models.base import Activity, DataSourceType, NcsPathwayType from ..utils import clean_filename, FileUtils, log, tr @@ -38,7 +39,7 @@ def calculate_irrecoverable_carbon_from_mean( ncs_pathways_layer: QgsRasterLayer, ) -> float: - """Calculates the total irrecoverable carbon for protected NCS pathways + """Calculates the total irrecoverable carbon in tonnes for protected NCS pathways using the reference layer defined in settings that is based on the mean value per hectare. @@ -47,11 +48,15 @@ def calculate_irrecoverable_carbon_from_mean( of the center point of the reference pixel to determine whether the reference pixel will be considered in the computation. The use of these tools results in some valid intersecting pixels being excluded from the analysis. This is a known - issue that has been raised in the QGIS GitHub repo, hence the reason of - adopting this function. + issue that has been raised in the QGIS GitHub repo, hence the reason for + using this function. :param ncs_pathways_layer: Layer containing a union of protected NCS pathways. - The CRS needs to be WGS84 otherwise the result will be incorrect. + The CRS needs to be WGS84 otherwise the result will be incorrect. In addition, + the layer needs to be in binary form i.e. a pixel value of 1 represents a + valid value and 0 represents a non-valid or nodata value. The raster boolean + (AND or OR) tool can be used to normalize the layer before passing it into + this function. :type ncs_pathways_layer: QgsRasterLayer :returns: The total irrecoverable carbon for protected NCS pathways @@ -187,23 +192,26 @@ def calculate_irrecoverable_carbon_from_mean( 1, analysis_extent, ncs_cols, ncs_rows ) ncs_block_data = ncs_block.data() - - fill_data = QtCore.QByteArray() - if ncs_pathways_layer.dataProvider().sourceHasNoDataValue(1): - # If there are no overlaps, the block will contain nodata values - fill_data = ncs_block.valueBytes( - ncs_block.dataType(), ncs_block.noDataValue() - ) + invalid_data = QgsRasterBlock.valueBytes(ncs_block.dataType(), 0.0) # Check if the NCS block within the reference block contains - # any other values apart from nodata. - ncs_ba_set = set(ncs_block_data[i] for i in range(ncs_block_data.size())) - fill_ba_set = set(fill_data[i] for i in range(fill_data.size())) + # any other value apart from the invalid value i.e. 0 pixel value. + # In future iterations, consider using QGIS 3.40+ which includes + # QgsRasterBlock.as_numpy() that provides the ability to work with + # the raw binary data in numpy. + ncs_ba_set = set( + ncs_block_data[i] for i in range(ncs_block_data.size()) + ) + invalid_ba_set = set( + invalid_data[i] for i in range(invalid_data.size()) + ) - if ncs_ba_set - fill_ba_set: + if ncs_ba_set - invalid_ba_set: # we have valid overlapping pixels hence we can pick the value of - # the reference IC layer. - irrecoverable_carbon_intersecting_pixel_values.append(block.value(r, c)) + # the corresponding reference IC layer. + irrecoverable_carbon_intersecting_pixel_values.append( + block.value(r, c) + ) reference_layer_iterator.stopRasterRead(1) @@ -213,8 +221,60 @@ def calculate_irrecoverable_carbon_from_mean( "No protected NCS pathways were found in the reference layer.", info=False, ) - return -1.0 + return 0.0 ic_mean = sum(irrecoverable_carbon_intersecting_pixel_values) / float(ic_count) return MEAN_REFERENCE_LAYER_AREA * ic_count * ic_mean + + +class IrrecoverableCarbonCalculator: + """Calculates the total irrecoverable carbon of an activity using + the mean-based reference carbon layer. + + It specifically searches for protected pathways in the activity. + If none is found, it will return 0. + """ + + def __init__(self, activity: typing.Union[str, Activity]): + if isinstance(activity, str): + activity = settings_manager.get_activity(activity) + + self._activity = activity + + @property + def activity(self) -> Activity: + """Gets the activity used to calculate the total + irrecoverable carbon. + + :returns: The activity for calculating the total + irrecoverable carbon. + :rtype: Activity + """ + return self._activity + + def calculate(self) -> float: + """Calculates the total irrecoverable carbon of the referenced activity. + + :returns: The total irrecoverable carbon of the activity. If there are + no protected NCS pathways in the activity, the function will return 0.0. + If there are any errors encountered during the process, the function + will return -1.0. + :rtype: float + """ + if len(self._activity.pathways) == 0: + log(f"There are no pathways in {self._activity.name} activity.") + return 0.0 + + protected_pathways = [ + pathway + for pathway in self._activity.pathways + if pathway.pathway_type == NcsPathwayType.PROTECTION + ] + + if len(protected_pathways) == 0: + log( + f"There are no protection pathways in " + f"{self._activity.name} activity." + ) + return 0.0 diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 097946a4e..638c7ff5d 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -11,11 +11,18 @@ QgsExpressionContextGenerator, QgsExpressionContextScope, QgsExpressionContextUtils, + QgsExpressionNodeFunction, QgsProject, QgsScopedExpressionFunction, ) -from ...definitions.defaults import BASE_PLUGIN_NAME +from ...definitions.defaults import ( + BASE_PLUGIN_NAME, + MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION, + NPV_EXPRESSION_DESCRIPTION, + PWL_IMPACT_EXPRESSION_DESCRIPTION, +) +from ..carbon import IrrecoverableCarbonCalculator from ...models.report import ActivityContextInfo, MetricEvalResult from ...utils import function_help_to_html, log, tr @@ -27,6 +34,64 @@ VAR_ACTIVITY_NAME = "cplus_activity_name" VAR_ACTIVITY_ID = "cplus_activity_id" +# Function names +FUNC_MEAN_BASED_IC = "irrecoverable_carbon_by_mean" + + +class ActivityIrrecoverableCarbonFunction(QgsScopedExpressionFunction): + """Calculates the total irrecoverable carbon of an activity using the + means-based reference carbon layer.""" + + def __init__(self): + help_html = function_help_to_html( + FUNC_MEAN_BASED_IC, + tr(MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION), + examples=[(f"{FUNC_MEAN_BASED_IC}()", "42,500")], + ) + super().__init__( + FUNC_MEAN_BASED_IC, 0, BASE_PLUGIN_NAME, help_html, isContextual=True + ) + + def func( + self, + values: typing.List[typing.Any], + context: QgsExpressionContext, + parent: QgsExpression, + node: QgsExpressionNodeFunction, + ) -> typing.Any: + """Returns the result of evaluating the function. + + :param values: List of values passed to the function + :type values: typing.Iterable[typing.Any] + + :param context: Context expression is being evaluated against + :type context: QgsExpressionContext + + :param parent: Parent expression + :type parent: QgsExpression + + :param node: Expression node + :type node: QgsExpressionNodeFunction + + :returns: The result of the function. + :rtype: typing.Any + """ + if not context.hasVariable(VAR_ACTIVITY_ID): + return -1.0 + + activity_id = context.variable(VAR_ACTIVITY_ID) + irrecoverable_carbon_calculator = IrrecoverableCarbonCalculator(activity_id) + + return irrecoverable_carbon_calculator.calculate() + + def clone(self) -> "ActivityIrrecoverableCarbonFunction": + """Gets a clone of this function. + + :returns: A clone of this function. + :rtype: ActivityIrrecoverableCarbonFunction + """ + return ActivityIrrecoverableCarbonFunction() + def create_metrics_expression_scope() -> QgsExpressionContextScope: """Creates the expression context scope for activity metrics. @@ -60,12 +125,19 @@ def create_metrics_expression_scope() -> QgsExpressionContextScope: ) ) + # Add functions + expression_scope.addFunction( + FUNC_MEAN_BASED_IC, ActivityIrrecoverableCarbonFunction() + ) + return expression_scope def register_metric_functions(): """Register our custom functions with the expression engine.""" - # Add expression functions to be registered here + # Irrecoverable carbon + mean_based_irrecoverable_carbon_function = ActivityIrrecoverableCarbonFunction() + METRICS_LIBRARY.append(mean_based_irrecoverable_carbon_function) for func in METRICS_LIBRARY: QgsExpression.registerFunction(func) From 778bd2b1beff0c6e5bd071c8382607732a1176a4 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 9 Dec 2024 17:03:27 +0300 Subject: [PATCH 07/25] Fixes for calculation of IC. --- src/cplus_plugin/lib/carbon.py | 178 +++++++++++++++++++++--- src/cplus_plugin/lib/reports/metrics.py | 2 +- 2 files changed, 163 insertions(+), 17 deletions(-) diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 39a1dd010..599010d5d 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -10,7 +10,9 @@ from itertools import count from qgis.core import ( + QgsCoordinateReferenceSystem, QgsFeedback, + QgsProcessing, QgsProcessingContext, QgsProcessingException, QgsProcessingFeedback, @@ -51,7 +53,7 @@ def calculate_irrecoverable_carbon_from_mean( issue that has been raised in the QGIS GitHub repo, hence the reason for using this function. - :param ncs_pathways_layer: Layer containing a union of protected NCS pathways. + :param ncs_pathways_layer: Layer containing an aggregate of protected NCS pathways. The CRS needs to be WGS84 otherwise the result will be incorrect. In addition, the layer needs to be in binary form i.e. a pixel value of 1 represents a valid value and 0 represents a non-valid or nodata value. The raster boolean @@ -65,7 +67,10 @@ def calculate_irrecoverable_carbon_from_mean( :rtype: float """ if not ncs_pathways_layer.isValid(): - log("Input union of protected NCS pathways is invalid.", info=False) + log( + "Irrecoverable Carbon Calculation - Input union of protected NCS pathways is invalid.", + info=False, + ) return -1.0 # TBC - should we cancel the process if using IC is disabled in settings? If @@ -95,7 +100,7 @@ def calculate_irrecoverable_carbon_from_mean( norm_source_path = os.path.normpath(reference_source_path) if not os.path.exists(norm_source_path): error_msg = ( - f"Data source for reference irrecoverable carbon layer " + f"Irrecoverable Carbon Calculation - Data source for reference irrecoverable carbon layer " f"{norm_source_path} does not exist." ) log(error_msg, info=False) @@ -108,27 +113,42 @@ def calculate_irrecoverable_carbon_from_mean( # Check CRS and warn if different if reference_irrecoverable_carbon_layer.crs() != ncs_pathways_layer.crs(): log( - "Final computation might be incorrect as protected NCS " + "Irrecoverable Carbon Calculation - Final computation might be incorrect as protected NCS " "pathways and reference irrecoverable carbon layer have different " "CRSs.", info=False, ) - reference_extent = reference_irrecoverable_carbon_layer.extent() + scenario_extent = settings_manager.get_value(Settings.SCENARIO_EXTENT) + if scenario_extent is None: + log( + "Irrecoverable Carbon Calculation - Scenario extent not defined.", + info=False, + ) + return -1.0 + + reference_extent = QgsRectangle( + float(scenario_extent[0]), + float(scenario_extent[2]), + float(scenario_extent[1]), + float(scenario_extent[3]), + ) + ncs_pathways_extent = ncs_pathways_layer.extent() # if they do not intersect then exit. This might also be related to the CRS. if not reference_extent.intersects(ncs_pathways_extent): log( - "The protected NCS pathways layer does not intersect with " + "Irrecoverable Carbon Calculation - The protected NCS pathways layer does not intersect with " "the reference irrecoverable carbon layer.", info=False, ) return -1.0 + # reference_provider = reference_irrecoverable_carbon_layer.dataProvider() reference_layer_iterator = QgsRasterIterator(reference_provider) reference_layer_iterator.startRasterRead( - 1, reference_provider.xSize(), reference_provider.ySize(), reference_extent + 1, reference_provider.xSize(), reference_provider.ySize(), ncs_pathways_extent ) irrecoverable_carbon_intersecting_pixel_values = [] @@ -145,7 +165,14 @@ def calculate_irrecoverable_carbon_from_mean( if not success: log( - "Unable to read the reference irrecoverable carbon layer.", + "Irrecoverable Carbon Calculation - Unable to read the reference irrecoverable carbon layer.", + info=False, + ) + break + + if not block.isValid(): + log( + "Irrecoverable Carbon Calculation - Invalid irrecoverable carbon layer raster block.", info=False, ) break @@ -191,6 +218,13 @@ def calculate_irrecoverable_carbon_from_mean( ncs_block = ncs_pathways_layer.dataProvider().block( 1, analysis_extent, ncs_cols, ncs_rows ) + if not ncs_block.isValid(): + log( + "Irrecoverable Carbon Calculation - Invalid aggregated NCS pathway raster block.", + info=False, + ) + continue + ncs_block_data = ncs_block.data() invalid_data = QgsRasterBlock.valueBytes(ncs_block.dataType(), 0.0) @@ -218,7 +252,7 @@ def calculate_irrecoverable_carbon_from_mean( ic_count = len(irrecoverable_carbon_intersecting_pixel_values) if count == 0: log( - "No protected NCS pathways were found in the reference layer.", + "Irrecoverable Carbon Calculation - No protected NCS pathways were found in the reference layer.", info=False, ) return 0.0 @@ -233,7 +267,8 @@ class IrrecoverableCarbonCalculator: the mean-based reference carbon layer. It specifically searches for protected pathways in the activity. - If none is found, it will return 0. + If none is found, it will return 0. This is designed to be called + within a QgsExpressionFunction. """ def __init__(self, activity: typing.Union[str, Activity]): @@ -253,7 +288,7 @@ def activity(self) -> Activity: """ return self._activity - def calculate(self) -> float: + def run(self) -> float: """Calculates the total irrecoverable carbon of the referenced activity. :returns: The total irrecoverable carbon of the activity. If there are @@ -263,18 +298,129 @@ def calculate(self) -> float: :rtype: float """ if len(self._activity.pathways) == 0: - log(f"There are no pathways in {self._activity.name} activity.") + log( + f"Irrecoverable Carbon Calculation - There are no pathways in " + f"{self._activity.name} activity.", + info=False, + ) return 0.0 - protected_pathways = [ + protection_pathways = [ pathway for pathway in self._activity.pathways if pathway.pathway_type == NcsPathwayType.PROTECTION ] - if len(protected_pathways) == 0: + if len(protection_pathways) == 0: + log( + f"Irrecoverable Carbon Calculation - There are no protection pathways in " + f"{self._activity.name} activity.", + info=False, + ) + return 0.0 + + protection_layers = [pathway.to_map_layer() for pathway in protection_pathways] + valid_protection_layers = [ + layer for layer in protection_layers if layer.isValid() + ] + if len(valid_protection_layers) == 0: log( - f"There are no protection pathways in " - f"{self._activity.name} activity." + f"Irrecoverable Carbon Calculation - There are no valid protection pathway layers in " + f"{self._activity.name} activity.", + info=False, ) return 0.0 + + if len(valid_protection_layers) != len(protection_layers): + # Just warn if some layers were excluded + log( + f"Irrecoverable Carbon Calculation - Some protection pathway layers are invalid and will be " + f"exclude from the irrecoverable carbon calculation.", + info=False, + ) + + # Perform a union of the pathways + processing_context = QgsProcessingContext() + + protected_data_sources = [layer.source() for layer in valid_protection_layers] + + boolean_args = { + "INPUT": protected_data_sources, + "REF_LAYER": protected_data_sources[0], + "NODATA_AS_FALSE": True, + "NO_DATA": -9999, + "OUTPUT": "D:/Temp/cplus/blnor.tif", + } + boolean_result = None + try: + boolean_result = processing.run( + "native:rasterlogicalor", + boolean_args, + context=processing_context, + ) + except QgsProcessingException as ex: + log( + "Irrecoverable Carbon Calculation - Error creating a union of protection NCS pathways.", + info=False, + ) + return -1.0 + + aggregate_raster_path = boolean_result["OUTPUT"] + log(aggregate_raster_path) + aggregate_layer = QgsRasterLayer(aggregate_raster_path, "aggregate_pathways") + if not aggregate_layer.isValid(): + log( + "Irrecoverable Carbon Calculation - Aggregate protection pathways layer is invalid.", + info=False, + ) + return -1.0 + + # Reproject the aggregated protection raster + reproject_args = { + "INPUT": aggregate_raster_path, + "SOURCE_CRS": valid_protection_layers[0].crs(), + "TARGET_CRS": QgsCoordinateReferenceSystem( + "EPSG:4326" + ), # Global IC reference raster + "RESAMPLING": 0, + "DATA_TYPE": 0, + "OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|ZLEVEL=9", + "OUTPUT": "D:/Temp/cplus/reproject.tif", + "EXTRA": "--config CHECK_DISK_FREE_SPACE NO", + } + reproject_result = None + try: + reproject_result = processing.run( + "gdal:warpreproject", + reproject_args, + context=QgsProcessingContext(), + ) + except QgsProcessingException as ex: + log( + "Irrecoverable Carbon Calculation - Error re-projecting the aggregate protection NCS pathways.", + info=False, + ) + return -1.0 + + reprojected_raster_path = reproject_result["OUTPUT"] + + reprojected_protection_layer = QgsRasterLayer( + reprojected_raster_path, "reprojected_protection_pathway" + ) + if not reprojected_protection_layer.isValid(): + log( + "Irrecoverable Carbon Calculation - Reprojected protection pathways layer is invalid.", + info=False, + ) + return -1.0 + + total_irrecoverable_carbon = calculate_irrecoverable_carbon_from_mean( + reprojected_protection_layer + ) + if total_irrecoverable_carbon == -1.0: + log( + "Irrecoverable Carbon Calculation - Error occurred in calculating the total irrecoverable carbon. See logs for details.", + info=False, + ) + + return total_irrecoverable_carbon diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 638c7ff5d..8d66b729f 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -82,7 +82,7 @@ def func( activity_id = context.variable(VAR_ACTIVITY_ID) irrecoverable_carbon_calculator = IrrecoverableCarbonCalculator(activity_id) - return irrecoverable_carbon_calculator.calculate() + return irrecoverable_carbon_calculator.run() def clone(self) -> "ActivityIrrecoverableCarbonFunction": """Gets a clone of this function. From b3329f49e2abca6c656b67855933d5ac85ee8317 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 9 Dec 2024 18:49:59 +0300 Subject: [PATCH 08/25] Change NCS pathway type terminology. --- .../gui/ncs_pathway_editor_dialog.py | 24 +++--- src/cplus_plugin/lib/carbon.py | 85 +++++++++---------- src/cplus_plugin/models/base.py | 14 +-- .../ui/ncs_pathway_editor_dialog.ui | 6 +- 4 files changed, 63 insertions(+), 66 deletions(-) diff --git a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py index 2483305b5..1e96c8d5b 100644 --- a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py +++ b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py @@ -89,13 +89,13 @@ def __init__(self, parent=None, ncs_pathway=None, excluded_names=None): self._pathway_type_group = QtWidgets.QButtonGroup(self) self._pathway_type_group.addButton( - self.rb_protection, NcsPathwayType.PROTECTION.value + self.rb_protection, NcsPathwayType.PROTECT.value ) self._pathway_type_group.addButton( - self.rb_restoration, NcsPathwayType.RESTORATION.value + self.rb_restoration, NcsPathwayType.RESTORE.value ) self._pathway_type_group.addButton( - self.rb_management, NcsPathwayType.MANAGEMENT.value + self.rb_management, NcsPathwayType.MANAGE.value ) self._excluded_names = excluded_names @@ -159,11 +159,11 @@ def _update_controls(self): self.txt_name.setText(self._ncs_pathway.name) self.txt_description.setPlainText(self._ncs_pathway.description) - if self._ncs_pathway.pathway_type == NcsPathwayType.PROTECTION: + if self._ncs_pathway.pathway_type == NcsPathwayType.PROTECT: self.rb_protection.setChecked(True) - if self._ncs_pathway.pathway_type == NcsPathwayType.RESTORATION: + if self._ncs_pathway.pathway_type == NcsPathwayType.RESTORE: self.rb_restoration.setChecked(True) - if self._ncs_pathway.pathway_type == NcsPathwayType.MANAGEMENT: + if self._ncs_pathway.pathway_type == NcsPathwayType.MANAGE: self.rb_management.setChecked(True) if self._layer: @@ -266,12 +266,12 @@ def _create_update_ncs_pathway(self): self._ncs_pathway.description = self.txt_description.toPlainText() selected_pathway_type_id = self._pathway_type_group.checkedId() - if selected_pathway_type_id == NcsPathwayType.PROTECTION.value: - self._ncs_pathway.pathway_type = NcsPathwayType.PROTECTION - elif selected_pathway_type_id == NcsPathwayType.RESTORATION.value: - self._ncs_pathway.pathway_type = NcsPathwayType.RESTORATION - elif selected_pathway_type_id == NcsPathwayType.MANAGEMENT.value: - self._ncs_pathway.pathway_type = NcsPathwayType.MANAGEMENT + if selected_pathway_type_id == NcsPathwayType.PROTECT.value: + self._ncs_pathway.pathway_type = NcsPathwayType.PROTECT + elif selected_pathway_type_id == NcsPathwayType.RESTORE.value: + self._ncs_pathway.pathway_type = NcsPathwayType.RESTORE + elif selected_pathway_type_id == NcsPathwayType.MANAGE.value: + self._ncs_pathway.pathway_type = NcsPathwayType.MANAGE self._ncs_pathway.layer_type = LayerType.RASTER default_layer = self._get_selected_default_layer() diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 599010d5d..3aa5d4def 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -41,7 +41,7 @@ def calculate_irrecoverable_carbon_from_mean( ncs_pathways_layer: QgsRasterLayer, ) -> float: - """Calculates the total irrecoverable carbon in tonnes for protected NCS pathways + """Calculates the total irrecoverable carbon in tonnes for protect NCS pathways using the reference layer defined in settings that is based on the mean value per hectare. @@ -53,7 +53,7 @@ def calculate_irrecoverable_carbon_from_mean( issue that has been raised in the QGIS GitHub repo, hence the reason for using this function. - :param ncs_pathways_layer: Layer containing an aggregate of protected NCS pathways. + :param ncs_pathways_layer: Layer containing an aggregate of protect NCS pathways. The CRS needs to be WGS84 otherwise the result will be incorrect. In addition, the layer needs to be in binary form i.e. a pixel value of 1 represents a valid value and 0 represents a non-valid or nodata value. The raster boolean @@ -61,14 +61,14 @@ def calculate_irrecoverable_carbon_from_mean( this function. :type ncs_pathways_layer: QgsRasterLayer - :returns: The total irrecoverable carbon for protected NCS pathways + :returns: The total irrecoverable carbon for protect NCS pathways specified in the input. If there are any errors during the operation, such as an invalid input raster layer, then -1.0 will be returned. :rtype: float """ if not ncs_pathways_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Input union of protected NCS pathways is invalid.", + "Irrecoverable Carbon Calculation - Input union of protect NCS pathways is invalid.", info=False, ) return -1.0 @@ -113,7 +113,7 @@ def calculate_irrecoverable_carbon_from_mean( # Check CRS and warn if different if reference_irrecoverable_carbon_layer.crs() != ncs_pathways_layer.crs(): log( - "Irrecoverable Carbon Calculation - Final computation might be incorrect as protected NCS " + "Irrecoverable Carbon Calculation - Final computation might be incorrect as protect NCS " "pathways and reference irrecoverable carbon layer have different " "CRSs.", info=False, @@ -138,7 +138,7 @@ def calculate_irrecoverable_carbon_from_mean( # if they do not intersect then exit. This might also be related to the CRS. if not reference_extent.intersects(ncs_pathways_extent): log( - "Irrecoverable Carbon Calculation - The protected NCS pathways layer does not intersect with " + "Irrecoverable Carbon Calculation - The protect NCS pathways layer does not intersect with " "the reference irrecoverable carbon layer.", info=False, ) @@ -148,7 +148,7 @@ def calculate_irrecoverable_carbon_from_mean( reference_provider = reference_irrecoverable_carbon_layer.dataProvider() reference_layer_iterator = QgsRasterIterator(reference_provider) reference_layer_iterator.startRasterRead( - 1, reference_provider.xSize(), reference_provider.ySize(), ncs_pathways_extent + 1, reference_provider.xSize(), reference_provider.ySize(), reference_extent ) irrecoverable_carbon_intersecting_pixel_values = [] @@ -164,10 +164,6 @@ def calculate_irrecoverable_carbon_from_mean( ) = reference_layer_iterator.readNextRasterPart(1) if not success: - log( - "Irrecoverable Carbon Calculation - Unable to read the reference irrecoverable carbon layer.", - info=False, - ) break if not block.isValid(): @@ -250,9 +246,9 @@ def calculate_irrecoverable_carbon_from_mean( reference_layer_iterator.stopRasterRead(1) ic_count = len(irrecoverable_carbon_intersecting_pixel_values) - if count == 0: + if ic_count == 0: log( - "Irrecoverable Carbon Calculation - No protected NCS pathways were found in the reference layer.", + "Irrecoverable Carbon Calculation - No protect NCS pathways were found in the reference layer.", info=False, ) return 0.0 @@ -266,7 +262,7 @@ class IrrecoverableCarbonCalculator: """Calculates the total irrecoverable carbon of an activity using the mean-based reference carbon layer. - It specifically searches for protected pathways in the activity. + It specifically searches for protect pathways in the activity. If none is found, it will return 0. This is designed to be called within a QgsExpressionFunction. """ @@ -292,7 +288,7 @@ def run(self) -> float: """Calculates the total irrecoverable carbon of the referenced activity. :returns: The total irrecoverable carbon of the activity. If there are - no protected NCS pathways in the activity, the function will return 0.0. + no protect NCS pathways in the activity, the function will return 0.0. If there are any errors encountered during the process, the function will return -1.0. :rtype: float @@ -305,36 +301,34 @@ def run(self) -> float: ) return 0.0 - protection_pathways = [ + protect_pathways = [ pathway for pathway in self._activity.pathways - if pathway.pathway_type == NcsPathwayType.PROTECTION + if pathway.pathway_type == NcsPathwayType.PROTECT ] - if len(protection_pathways) == 0: + if len(protect_pathways) == 0: log( - f"Irrecoverable Carbon Calculation - There are no protection pathways in " + f"Irrecoverable Carbon Calculation - There are no protect pathways in " f"{self._activity.name} activity.", info=False, ) return 0.0 - protection_layers = [pathway.to_map_layer() for pathway in protection_pathways] - valid_protection_layers = [ - layer for layer in protection_layers if layer.isValid() - ] - if len(valid_protection_layers) == 0: + protect_layers = [pathway.to_map_layer() for pathway in protect_pathways] + valid_protect_layers = [layer for layer in protect_layers if layer.isValid()] + if len(valid_protect_layers) == 0: log( - f"Irrecoverable Carbon Calculation - There are no valid protection pathway layers in " + f"Irrecoverable Carbon Calculation - There are no valid protect pathway layers in " f"{self._activity.name} activity.", info=False, ) return 0.0 - if len(valid_protection_layers) != len(protection_layers): + if len(valid_protect_layers) != len(protect_layers): # Just warn if some layers were excluded log( - f"Irrecoverable Carbon Calculation - Some protection pathway layers are invalid and will be " + f"Irrecoverable Carbon Calculation - Some protect pathway layers are invalid and will be " f"exclude from the irrecoverable carbon calculation.", info=False, ) @@ -342,14 +336,15 @@ def run(self) -> float: # Perform a union of the pathways processing_context = QgsProcessingContext() - protected_data_sources = [layer.source() for layer in valid_protection_layers] + protect_data_sources = [layer.source() for layer in valid_protect_layers] boolean_args = { - "INPUT": protected_data_sources, - "REF_LAYER": protected_data_sources[0], + "INPUT": protect_data_sources, + "REF_LAYER": protect_data_sources[0], "NODATA_AS_FALSE": True, "NO_DATA": -9999, - "OUTPUT": "D:/Temp/cplus/blnor.tif", + "DATA_TYPE": 11, # Since we are only dealing wih 0s and 1s, this will help reduce the size of the output file. + "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, } boolean_result = None try: @@ -360,32 +355,31 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - "Irrecoverable Carbon Calculation - Error creating a union of protection NCS pathways.", + "Irrecoverable Carbon Calculation - Error creating a union of protect NCS pathways.", info=False, ) return -1.0 aggregate_raster_path = boolean_result["OUTPUT"] - log(aggregate_raster_path) aggregate_layer = QgsRasterLayer(aggregate_raster_path, "aggregate_pathways") if not aggregate_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Aggregate protection pathways layer is invalid.", + "Irrecoverable Carbon Calculation - Aggregate protect pathways layer is invalid.", info=False, ) return -1.0 - # Reproject the aggregated protection raster + # Reproject the aggregated protect raster reproject_args = { "INPUT": aggregate_raster_path, - "SOURCE_CRS": valid_protection_layers[0].crs(), + "SOURCE_CRS": valid_protect_layers[0].crs(), "TARGET_CRS": QgsCoordinateReferenceSystem( "EPSG:4326" ), # Global IC reference raster "RESAMPLING": 0, "DATA_TYPE": 0, "OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|ZLEVEL=9", - "OUTPUT": "D:/Temp/cplus/reproject.tif", + "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, "EXTRA": "--config CHECK_DISK_FREE_SPACE NO", } reproject_result = None @@ -397,29 +391,32 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - "Irrecoverable Carbon Calculation - Error re-projecting the aggregate protection NCS pathways.", + "Irrecoverable Carbon Calculation - Error re-projecting the " + "aggregate protect NCS pathways.", info=False, ) return -1.0 reprojected_raster_path = reproject_result["OUTPUT"] - reprojected_protection_layer = QgsRasterLayer( - reprojected_raster_path, "reprojected_protection_pathway" + reprojected_protect_layer = QgsRasterLayer( + reprojected_raster_path, "reprojected_protect_pathway" ) - if not reprojected_protection_layer.isValid(): + if not reprojected_protect_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Reprojected protection pathways layer is invalid.", + "Irrecoverable Carbon Calculation - Reprojected " + "protect pathways layer is invalid.", info=False, ) return -1.0 total_irrecoverable_carbon = calculate_irrecoverable_carbon_from_mean( - reprojected_protection_layer + reprojected_protect_layer ) if total_irrecoverable_carbon == -1.0: log( - "Irrecoverable Carbon Calculation - Error occurred in calculating the total irrecoverable carbon. See logs for details.", + "Irrecoverable Carbon Calculation - Error occurred in " + "calculating the total irrecoverable carbon. See logs for details.", info=False, ) diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index 4eeaf623a..de905320f 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -281,10 +281,10 @@ def is_default_layer(self) -> bool: class NcsPathwayType(IntEnum): """Type of NCS pathway.""" - PROTECTION = 0 - RESTORATION = 1 - MANAGEMENT = 2 - UNDEFINED = 99 + PROTECT = 0 + RESTORE = 1 + MANAGE = 2 + UNDEFINED = -1 @staticmethod def from_int(int_enum: int) -> "NcsPathwayType": @@ -298,11 +298,11 @@ def from_int(int_enum: int) -> "NcsPathwayType": :rtype: NcsPathwayType """ if int_enum == 0: - return NcsPathwayType.PROTECTION + return NcsPathwayType.PROTECT elif int_enum == 1: - return NcsPathwayType.RESTORATION + return NcsPathwayType.RESTORE elif int_enum == 2: - return NcsPathwayType.MANAGEMENT + return NcsPathwayType.MANAGE else: return NcsPathwayType.UNDEFINED diff --git a/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui b/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui index 6c581863e..0538b1c42 100644 --- a/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui +++ b/src/cplus_plugin/ui/ncs_pathway_editor_dialog.ui @@ -280,21 +280,21 @@ - Protection + Protect - Restoration + Manage - Management + Restore From 6268a1413f0951553aa25c65cd1fd0699613a549 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 10 Dec 2024 10:54:37 +0300 Subject: [PATCH 09/25] Add more detailed messages in report progress dialog. --- src/cplus_plugin/gui/qgis_cplus_main.py | 15 ++++ src/cplus_plugin/lib/carbon.py | 73 +++++++++-------- src/cplus_plugin/lib/reports/generator.py | 97 ++++++++++++++++++----- src/cplus_plugin/lib/reports/manager.py | 14 ++++ 4 files changed, 146 insertions(+), 53 deletions(-) diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 3ef25af8d..c707f6035 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -1509,6 +1509,7 @@ def run_cplus_main_task(self, progress_dialog, scenario, analysis_task): progress_dialog.scenario_id = str(scenario.uuid) report_running = partial(self.on_report_running, progress_dialog) + report_status_changed = partial(self.on_report_status_changed, progress_dialog) report_error = partial(self.on_report_error, progress_dialog) report_finished = partial(self.on_report_finished, progress_dialog) @@ -1516,6 +1517,7 @@ def run_cplus_main_task(self, progress_dialog, scenario, analysis_task): scenario_report_manager = report_manager scenario_report_manager.generate_started.connect(report_running) + scenario_report_manager.status_changed.connect(report_status_changed) scenario_report_manager.generate_error.connect(report_error) scenario_report_manager.generate_completed.connect(report_finished) @@ -2573,6 +2575,19 @@ def on_report_running(self, progress_dialog, scenario_id: str): tr("Generating report for the analysis output") ) + def on_report_status_changed(self, progress_dialog, message: str): + """Slot raised when report task status has changed. + + :param progress_dialog: Dialog responsible for showing + all the analysis operations progress. + :type progress_dialog: ProgressDialog + + :param message: Status message. + :type message: str + """ + status_message = f"{tr('Report generation')} - {message}..." + progress_dialog.change_status_message(status_message) + def on_report_error(self, progress_dialog, message: str): """Slot raised when report task error has occured. diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 3aa5d4def..6e2bc9c39 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -245,17 +245,19 @@ def calculate_irrecoverable_carbon_from_mean( reference_layer_iterator.stopRasterRead(1) - ic_count = len(irrecoverable_carbon_intersecting_pixel_values) - if ic_count == 0: + ic_pixel_count = len(irrecoverable_carbon_intersecting_pixel_values) + if ic_pixel_count == 0: log( "Irrecoverable Carbon Calculation - No protect NCS pathways were found in the reference layer.", info=False, ) return 0.0 - ic_mean = sum(irrecoverable_carbon_intersecting_pixel_values) / float(ic_count) + ic_mean = sum(irrecoverable_carbon_intersecting_pixel_values) / float( + ic_pixel_count + ) - return MEAN_REFERENCE_LAYER_AREA * ic_count * ic_mean + return MEAN_REFERENCE_LAYER_AREA * ic_pixel_count * ic_mean class IrrecoverableCarbonCalculator: @@ -360,8 +362,8 @@ def run(self) -> float: ) return -1.0 - aggregate_raster_path = boolean_result["OUTPUT"] - aggregate_layer = QgsRasterLayer(aggregate_raster_path, "aggregate_pathways") + carbon_calc_layer_path = boolean_result["OUTPUT"] + aggregate_layer = QgsRasterLayer(carbon_calc_layer_path, "aggregate_pathways") if not aggregate_layer.isValid(): log( "Irrecoverable Carbon Calculation - Aggregate protect pathways layer is invalid.", @@ -369,38 +371,39 @@ def run(self) -> float: ) return -1.0 - # Reproject the aggregated protect raster - reproject_args = { - "INPUT": aggregate_raster_path, - "SOURCE_CRS": valid_protect_layers[0].crs(), - "TARGET_CRS": QgsCoordinateReferenceSystem( - "EPSG:4326" - ), # Global IC reference raster - "RESAMPLING": 0, - "DATA_TYPE": 0, - "OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|ZLEVEL=9", - "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, - "EXTRA": "--config CHECK_DISK_FREE_SPACE NO", - } - reproject_result = None - try: - reproject_result = processing.run( - "gdal:warpreproject", - reproject_args, - context=QgsProcessingContext(), - ) - except QgsProcessingException as ex: - log( - "Irrecoverable Carbon Calculation - Error re-projecting the " - "aggregate protect NCS pathways.", - info=False, - ) - return -1.0 + # Reproject the aggregated protect raster if required + if aggregate_layer.crs() != QgsCoordinateReferenceSystem("EPSG:4326"): + reproject_args = { + "INPUT": carbon_calc_layer_path, + "SOURCE_CRS": valid_protect_layers[0].crs(), + "TARGET_CRS": QgsCoordinateReferenceSystem( + "EPSG:4326" + ), # Global IC reference raster + "RESAMPLING": 0, + "DATA_TYPE": 0, + "OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|ZLEVEL=9", + "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, + "EXTRA": "--config CHECK_DISK_FREE_SPACE NO", + } + reproject_result = None + try: + reproject_result = processing.run( + "gdal:warpreproject", + reproject_args, + context=QgsProcessingContext(), + ) + except QgsProcessingException as ex: + log( + "Irrecoverable Carbon Calculation - Error re-projecting the " + "aggregate protect NCS pathways.", + info=False, + ) + return -1.0 - reprojected_raster_path = reproject_result["OUTPUT"] + carbon_calc_layer_path = reproject_result["OUTPUT"] reprojected_protect_layer = QgsRasterLayer( - reprojected_raster_path, "reprojected_protect_pathway" + carbon_calc_layer_path, "reprojected_protect_pathway" ) if not reprojected_protect_layer.isValid(): log( diff --git a/src/cplus_plugin/lib/reports/generator.py b/src/cplus_plugin/lib/reports/generator.py index 3419200ea..736131d36 100644 --- a/src/cplus_plugin/lib/reports/generator.py +++ b/src/cplus_plugin/lib/reports/generator.py @@ -94,13 +94,16 @@ class BaseScenarioReportGeneratorTask(QgsTask): """Base proxy class for initiating the report generation process.""" + status_changed = QtCore.pyqtSignal(str) + def __init__(self, description: str, context: BaseReportContext): super().__init__(description) self._context = context self._result = None self._generator = BaseScenarioReportGenerator( - self._context, self._context.feedback + self, self._context, self._context.feedback ) + self._generator.status_changed.connect(self._on_status_changed) self.layout_manager = QgsProject.instance().layoutManager() self.layout_manager.layoutAdded.connect(self._on_layout_added) @@ -123,6 +126,14 @@ def result(self) -> ReportResult: """ return self._result + def _on_status_changed(self, message: str): + """Slot raised when the status of the generator has changed. + + :param message: Status message. + :type message: str + """ + self.status_changed.emit(message) + def cancel(self): """Cancel the report generation task.""" if self._context.feedback: @@ -195,8 +206,9 @@ class ScenarioAnalysisReportGeneratorTask(BaseScenarioReportGeneratorTask): def __init__(self, description: str, context: ReportContext): super().__init__(description, context) self._generator = ScenarioAnalysisReportGenerator( - context, self._context.feedback + self, context, self._context.feedback ) + self._generator.status_changed.connect(self._on_status_changed) def _zoom_map_items_to_current_extents(self, layout: QgsPrintLayout): """Zoom extents of map items in the layout to current map canvas @@ -307,12 +319,20 @@ def finished(self, result: bool): feedback.setProgress(100) -class BaseScenarioReportGenerator: +class BaseScenarioReportGenerator(QtCore.QObject): """Base class for generating a scenario report.""" + status_changed = QtCore.pyqtSignal(str) + AREA_DECIMAL_PLACES = DEFAULT_AREA_DECIMAL_PLACES - def __init__(self, context: BaseReportContext, feedback: QgsFeedback = None): + def __init__( + self, + parent: QtCore.QObject, + context: BaseReportContext, + feedback: QgsFeedback = None, + ): + super().__init__(parent) self._context = context self._feedback = context.feedback or feedback if self._feedback: @@ -376,18 +396,24 @@ def _on_feedback_canceled(self): """ pass - def _process_check_cancelled_or_set_progress(self, value: float) -> bool: + def _process_check_cancelled_or_set_progress( + self, value: float, status_message: str = "" + ) -> bool: """Check if there is a request to cancel the process if a feedback object had been specified. """ if (self._feedback and self._feedback.isCanceled()) or self._error_occurred: tr_msg = tr("Report generation cancelled.") self._error_messages.append(tr_msg) + self.status_changed.emit(tr_msg) return True self._feedback.setProgress(value) + if status_message: + self.status_changed.emit(status_message) + return False def _set_project(self): @@ -713,9 +739,12 @@ class ScenarioComparisonReportGenerator(DuplicatableRepeatPageReportGenerator): REPEAT_PAGE_ITEM_ID = "CPLUS Map Repeat Area 2" def __init__( - self, context: ScenarioComparisonReportContext, feedback: QgsFeedback = None + self, + parent: QtCore.QObject, + context: ScenarioComparisonReportContext, + feedback: QgsFeedback = None, ): - super().__init__(context, feedback) + super().__init__(parent, context, feedback) # Repeat item for half page one self._page_one_repeat_item = None @@ -1043,8 +1072,13 @@ def _run(self): class ScenarioAnalysisReportGenerator(DuplicatableRepeatPageReportGenerator): """Generator for CPLUS scenario analysis report.""" - def __init__(self, context: ReportContext, feedback: QgsFeedback = None): - super().__init__(context, feedback) + def __init__( + self, + parent: QtCore.QObject, + context: ReportContext, + feedback: QgsFeedback = None, + ): + super().__init__(parent, context, feedback) self._repeat_page = None self._repeat_page_num = -1 self._repeat_item = None @@ -1075,18 +1109,24 @@ def repeat_page(self) -> typing.Union[QgsLayoutItemPage, None]: """ return self._repeat_page - def _process_check_cancelled_or_set_progress(self, value: float) -> bool: + def _process_check_cancelled_or_set_progress( + self, value: float, status_message: str = "" + ) -> bool: """Check if there is a request to cancel the process if a feedback object had been specified. """ if (self._feedback and self._feedback.isCanceled()) or self._error_occurred: - tr_msg = tr("Report generation cancelled") + tr_msg = tr("Report generation cancelled.") self._error_messages.append(tr_msg) + self.status_changed.emit(tr_msg) return True self._feedback.setProgress(value) + if status_message: + self.status_changed.emit(status_message) + return False def _on_feedback_cancelled(self): @@ -1742,7 +1782,18 @@ def _populate_activity_area_table(self): highlight_error = False - for mc in self._metrics_configuration.metric_columns: + base_overall_progress = 70 + progress_increment = 15 / ( + float(len(self._metrics_configuration.metric_columns)) + * num_activities + ) + + for i, mc in enumerate(self._metrics_configuration.metric_columns): + progress = base_overall_progress + ((i + 1) * progress_increment) + tr_msg = f"{tr('Calculating')} {activity.name} {mc.header} metrics" + if self._process_check_cancelled_or_set_progress(progress, tr_msg): + return self._get_failed_result() + activity_metric = self._metrics_configuration.find( str(activity.uuid), mc.name ) @@ -1948,22 +1999,28 @@ def _run(self) -> ReportResult: # Set repeat page self._set_repeat_page() - if self._process_check_cancelled_or_set_progress(20): + if self._process_check_cancelled_or_set_progress( + 20, tr("initializing process") + ): return self._get_failed_result() - if self._process_check_cancelled_or_set_progress(45): + if self._process_check_cancelled_or_set_progress( + 45, tr("rendering repeat page") + ): return self._get_failed_result() # Render repeat items i.e. activities self._render_repeat_items() - if self._process_check_cancelled_or_set_progress(70): + if self._process_check_cancelled_or_set_progress( + 70, tr("populating activity metric table") + ): return self._get_failed_result() # Populate activity area table self._populate_activity_area_table() - if self._process_check_cancelled_or_set_progress(80): + if self._process_check_cancelled_or_set_progress(85, tr("rendering map items")): return self._get_failed_result() # Populate table with priority weighting values @@ -1978,14 +2035,18 @@ def _run(self) -> ReportResult: # Add CPLUS report flag self._variable_register.set_report_flag(self._layout) - if self._process_check_cancelled_or_set_progress(85): + if self._process_check_cancelled_or_set_progress( + 88, tr("saving report layout") + ): return self._get_failed_result() result = self._save_layout_to_file() if not result: return self._get_failed_result() - if self._process_check_cancelled_or_set_progress(90): + if self._process_check_cancelled_or_set_progress( + 90, tr("exporting report to PDF") + ): return self._get_failed_result() return ReportResult( diff --git a/src/cplus_plugin/lib/reports/manager.py b/src/cplus_plugin/lib/reports/manager.py index 3f981c438..a2648e5b7 100644 --- a/src/cplus_plugin/lib/reports/manager.py +++ b/src/cplus_plugin/lib/reports/manager.py @@ -56,6 +56,7 @@ class ReportManager(QtCore.QObject): generate_started = QtCore.pyqtSignal(str) generate_error = QtCore.pyqtSignal(str) generate_completed = QtCore.pyqtSignal(str) + status_changed = QtCore.pyqtSignal(str) # Max number of comparison report tasks COMPARISON_REPORT_LIMIT = 3 @@ -328,6 +329,8 @@ def generate( report_task.taskCompleted.connect(report_task_completed) report_task.taskTerminated.connect(report_task_completed) + report_task.status_changed.connect(self.on_analysis_status_changed) + task_id = self.task_manager.addTask(report_task) self._report_tasks[scenario_id] = task_id @@ -338,6 +341,17 @@ def report_task_completed(self, task): if len(task._result.messages) > 0: self.generate_error.emit(",".join(task._result.messages)) + def on_analysis_status_changed(self, message: str): + """Slot raised when the status for a scenario analysis changes. + + :param message: Status message. + :type message: str + """ + if not message: + return + + self.status_changed.emit(message) + def report_result(self, scenario_id: str) -> typing.Union[ReportResult, None]: """Gets the report result for the scenario with the given ID. From d1362d40ee9b0770504818c24e7f6d3fd0ed6aa5 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 11 Dec 2024 09:09:24 +0300 Subject: [PATCH 10/25] Update IC UI settings. --- .../gui/settings/cplus_options.py | 62 ++++++++++++++----- src/cplus_plugin/icons/downloading_svg.svg | 1 + src/cplus_plugin/ui/cplus_settings.ui | 40 +++++++----- 3 files changed, 72 insertions(+), 31 deletions(-) create mode 100644 src/cplus_plugin/icons/downloading_svg.svg diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index cda9afd61..d0332e1d3 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -536,6 +536,9 @@ def __init__(self, parent=None, main_widget=None) -> None: self.lbl_url_tip.setPixmap(FileUtils.get_pixmap("info_green.svg")) self.lbl_url_tip.setScaledContents(True) + self.btn_ic_download.setIcon(FileUtils.get_icon("downloading_svg.svg")) + self.btn_ic_download.clicked.connect(self.on_download_irrecoverable_carbon) + # Load gui default value from settings auth_id = settings.value("cplusplugin/auth") if auth_id is not None: @@ -787,6 +790,45 @@ def load_settings(self) -> None: self.validate_current_irrecoverable_data_source() + def validate_irrecoverable_carbon_url(self) -> bool: + """Checks if the irrecoverable data URL is valid. + + :returns: True if the link is valid else False if the + URL is empty, points to a local file or if not + well-formed. + :rtype: bool + """ + dataset_url = self.txt_ic_url.text() + if not dataset_url: + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined") + ) + + url_checker = QtCore.QUrl(dataset_url, QtCore.QUrl.StrictMode) + if url_checker.isLocalFile(): + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), + tr("Invalid URL referencing a local file"), + ) + else: + if not url_checker.isValid(): + self.message_bar.pushWarning( + tr("CPLUS - Irrecoverable carbon dataset"), + tr("URL is invalid."), + ) + + def on_download_irrecoverable_carbon(self): + """Slot raised to check download link and initiate download + process of the irrecoverable carbon data. + """ + valid_url = self.validate_irrecoverable_carbon_url() + if not valid_url: + log( + tr("Link for downloading irrecoverable carbon data is invalid."), + info=False, + ) + return + def validate_current_irrecoverable_data_source(self): """Checks if the currently selected irrecoverable data source is valid. @@ -799,25 +841,13 @@ def validate_current_irrecoverable_data_source(self): tr_msg = tr("CPLUS - Local irrecoverable carbon dataset not found") self.message_bar.pushWarning(tr_msg, local_path) elif self.rb_online.isChecked(): - dataset_url = self.txt_ic_url.text() - if not dataset_url: + _ = self.validate_irrecoverable_carbon_url() + if not self.fw_save_online_file.filePath(): + tr_msg = tr("CPLUS - Online irrecoverable carbon dataset") self.message_bar.pushWarning( - tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined") + tr_msg, tr("File path for saving dataset not defined") ) - url_checker = QtCore.QUrl(dataset_url, QtCore.QUrl.StrictMode) - if url_checker.isLocalFile(): - self.message_bar.pushWarning( - tr("CPLUS - Irrecoverable carbon dataset"), - tr("Invalid URL referencing a local file"), - ) - else: - if not url_checker.isValid(): - self.message_bar.pushWarning( - tr("CPLUS - Irrecoverable carbon dataset"), - tr("URL is invalid."), - ) - def showEvent(self, event: QShowEvent) -> None: """Show event being called. This will display the plugin settings. The stored/saved settings will be loaded. diff --git a/src/cplus_plugin/icons/downloading_svg.svg b/src/cplus_plugin/icons/downloading_svg.svg new file mode 100644 index 000000000..da8471a33 --- /dev/null +++ b/src/cplus_plugin/icons/downloading_svg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index 7824edea4..cc217eede 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -391,21 +391,24 @@ 2 - - + + - URL + Save as - - - - Specify the URL to fetch the dataset in the CI server + + + + + + + URL - + @@ -426,7 +429,7 @@ - The scenario extent defined in Step 1 will be used to clip the reference layer to be downloaded. The download process will start in the background on applying the settings. + QFrame::NoFrame @@ -436,15 +439,22 @@ - - - - File name + + + + Specify the URL to fetch the dataset in the CI server - - + + + + Initiate new download or refesh previous download + + + ... + +
From ed969d35fde2e436e9aa65c2d4fc848bfdf7064a Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 11 Dec 2024 11:39:21 +0300 Subject: [PATCH 11/25] Incorporate default IC download URL. --- src/cplus_plugin/conf.py | 3 +- src/cplus_plugin/definitions/defaults.py | 1 + .../gui/settings/cplus_options.py | 34 +++++++++++++++++++ src/cplus_plugin/lib/carbon.py | 2 +- src/cplus_plugin/main.py | 7 ++++ src/cplus_plugin/ui/cplus_settings.ui | 5 ++- 6 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 88047f113..5a3c241f3 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -255,9 +255,10 @@ class Settings(enum.Enum): IRRECOVERABLE_CARBON_SOURCE_TYPE = "carbon/irrecoverable_carbon_source_type" # Path for local data source IRRECOVERABLE_CARBON_LOCAL_SOURCE = "carbon/irrecoverable_carbon_source" + IRRECOVERABLE_CARBON_ONLINE_SOURCE = "carbon/irrecoverable_carbon_online_source" IRRECOVERABLE_CARBON_ENABLED = "carbon/irrecoverable_carbon_enabled" # Path where the online data source will be saved locally - IRRECOVERABLE_CARBON_ONLINE_LOCAL_SOURCE = ( + IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH = ( "carbon/irrecoverable_carbon_online_local_dir" ) diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index de26ef3c6..b029064f7 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -240,6 +240,7 @@ "Creative Commons Attribution 4.0 International " "License (CC BY 4.0)" ) BASE_API_URL = "https://stage.cplus.earth/api/v1" +IRRECOVERABLE_CARBON_API_URL = f"{BASE_API_URL}/reference_layer/carbon_calculation/" DEFAULT_BASE_COMPARISON_REPORT_NAME = "Scenario Comparison Report" MAXIMUM_COMPARISON_REPORTS = 10 diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index d0332e1d3..9b5107a9d 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -521,6 +521,8 @@ def __init__(self, parent=None, main_widget=None) -> None: self._on_irrecoverable_button_group_toggled ) + tif_file_filter = tr("GeoTIFF (*.tif *.tiff *.TIF *.TIFF)") + self.fw_irrecoverable_carbon.setDialogTitle( tr("Select Irrecoverable Carbon Dataset") ) @@ -528,11 +530,21 @@ def __init__(self, parent=None, main_widget=None) -> None: QgsFileWidget.RelativeStorage.Absolute ) self.fw_irrecoverable_carbon.setStorageMode(QgsFileWidget.StorageMode.GetFile) + self.fw_irrecoverable_carbon.setFilter(tif_file_filter) self.cbo_irrecoverable_carbon.layerChanged.connect( self._on_irrecoverable_carbon_layer_changed ) + self.fw_save_online_file.setDialogTitle( + tr("Specify Save Location of Irrecoverable Carbon Dataset") + ) + self.fw_save_online_file.setRelativeStorage( + QgsFileWidget.RelativeStorage.Absolute + ) + self.fw_save_online_file.setStorageMode(QgsFileWidget.StorageMode.SaveFile) + self.fw_save_online_file.setFilter(tif_file_filter) + self.lbl_url_tip.setPixmap(FileUtils.get_pixmap("info_green.svg")) self.lbl_url_tip.setScaledContents(True) @@ -689,6 +701,13 @@ def save_settings(self) -> None: settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, self.txt_ic_url.text() + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + self.fw_save_online_file.filePath(), + ) settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_ENABLED, @@ -772,20 +791,35 @@ def load_settings(self) -> None: else: self.gb_ic_reference_layer.setChecked(False) + # Local path self.fw_irrecoverable_carbon.setFilePath( settings_manager.get_value( Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, default="" ) ) + # Online config + self.txt_ic_url.setText( + settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="" + ) + ) + self.fw_save_online_file.setFilePath( + settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, default="" + ) + ) + source_type_int = settings_manager.get_value( Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, default=DataSourceType.UNDEFINED.value, setting_type=int, ) if source_type_int == DataSourceType.LOCAL.value: + self.rb_local.setChecked(True) self.sw_irrecoverable_carbon.setCurrentIndex(0) elif source_type_int == DataSourceType.ONLINE.value: + self.rb_online.setChecked(True) self.sw_irrecoverable_carbon.setCurrentIndex(1) self.validate_current_irrecoverable_data_source() diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 6e2bc9c39..585b31d23 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -87,7 +87,7 @@ def calculate_irrecoverable_carbon_from_mean( ) elif source_type_int == DataSourceType.ONLINE.value: reference_source_path = settings_manager.get_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_SOURCE, default="" + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, default="" ) if not reference_source_path: diff --git a/src/cplus_plugin/main.py b/src/cplus_plugin/main.py index 1887e7961..6476dc05e 100644 --- a/src/cplus_plugin/main.py +++ b/src/cplus_plugin/main.py @@ -40,6 +40,7 @@ DEFAULT_REPORT_LICENSE, DOCUMENTATION_SITE, ICON_PATH, + IRRECOVERABLE_CARBON_API_URL, OPTIONS_TITLE, PRIORITY_GROUPS, PRIORITY_LAYERS, @@ -464,6 +465,12 @@ def initialize_api_url(): settings_manager.set_value(Settings.DEBUG, False) if not settings_manager.get_value(Settings.BASE_API_URL, None, str): settings_manager.set_value(Settings.BASE_API_URL, BASE_API_URL) + if not settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, None, str + ): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL + ) def initialize_report_settings(): diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index cc217eede..7f8bcbf19 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -441,6 +441,9 @@
+ + Do not include the bbox PARAM, it will be automatically appended based on the current scenario extent + Specify the URL to fetch the dataset in the CI server @@ -449,7 +452,7 @@ - Initiate new download or refesh previous download + Initiate new download or refesh previous download in the background ... From 9dbcf5443b4c09fcb4fba3e9f6604e2511542814 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Thu, 12 Dec 2024 13:36:40 +0300 Subject: [PATCH 12/25] Initial implementation of IrrecoverableCarbonDownloadTask. --- src/cplus_plugin/api/carbon_layers.py | 162 ++++++++++++++++++ .../gui/settings/cplus_options.py | 4 +- src/cplus_plugin/models/helpers.py | 22 +++ src/cplus_plugin/ui/cplus_settings.ui | 79 ++++----- 4 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 src/cplus_plugin/api/carbon_layers.py diff --git a/src/cplus_plugin/api/carbon_layers.py b/src/cplus_plugin/api/carbon_layers.py new file mode 100644 index 000000000..a64e2f5e5 --- /dev/null +++ b/src/cplus_plugin/api/carbon_layers.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +""" +API requests for managing carbon layers. +""" +from numbers import Number +import os +from pathlib import Path +import traceback +import typing + +from qgis.core import QgsApplication, QgsFileDownloader, QgsRectangle, QgsTask +from qgis.PyQt import QtCore + +from ..conf import settings_manager, Settings +from ..models.helpers import extent_to_url_param +from ..utils import log, tr + + +class IrrecoverableCarbonDownloadTask(QgsTask): + """Class for downloading the irrecoverable carbon data from the online server.""" + + error_occurred = QtCore.pyqtSignal(list) + canceled = QtCore.pyqtSignal() + completed = QtCore.pyqtSignal() + + def __init__(self): + super().__init__(tr("Downloading irrecoverable carbon dataset")) + self._downloader = None + + def cancel(self): + """Cancel the download process.""" + if self._downloader: + self._downloader.cancelDownload() + + super().cancel() + + log("Irrecoverable carbon dataset task canceled.") + + def _on_error_occurred(self, error_messages: typing.List[str]): + """Slot raised when the downloader encounters an error. + + :param error_messages: Error messages. + :type error_messages: typing.List[str] + """ + self.error_occurred.emit(error_messages) + + err_msg = ", ".join(error_messages) + log(f"Error in downloading irrecoverable carbon dataset: {err_msg}", info=False) + + def _on_download_canceled(self): + """Slot raised when the download has been canceled.""" + self.canceled.emit() + + log("Download of irrecoverable carbon dataset canceled.") + + def _on_download_completed(self, url: QtCore.QUrl): + """Slot raised when the download is complete. + + :param url: Url of the file resource. + :type url: QtCore.QUrl + """ + self.completed.emit() + + log("Download of irrecoverable carbon dataset successfully completed.") + + def _on_progress_changed(self, received: int, total: int): + """Slot raised indicating progress made by the downloader. + + :param received: Bytes received. + :type received: int + + :param total: Total size of the file in bytes. + :type total: int + """ + self.setProgress(received / float(total) * 100) + + def run(self) -> bool: + """Initiates the report generation process and returns + a result indicating whether the process succeeded or + failed. + + :returns: True if the report generation process succeeded + or False it if failed. + :rtype: bool + """ + if self.isCanceled(): + return False + + # Get extents, URL and local path + extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None) + if extent is None: + if not extent: + log( + "Scenario extent not defined for downloading irrecoverable " + "carbon dataset.", + info=False, + ) + return False + + extent_rectangle = QgsRectangle( + float(extent[0]), float(extent[2]), float(extent[1]), float(extent[3]) + ) + url_bbox_part = extent_to_url_param(extent_rectangle) + + download_url_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="", setting_type=str + ) + if not download_url_path: + log("Source URL for irrecoverable carbon dataset not found.", info=False) + return False + + save_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + default="", + setting_type=str, + ) + if not save_path: + log( + "Save location for irrecoverable carbon dataset not specified.", + info=False, + ) + return False + + self._downloader = QgsFileDownloader( + QtCore.QUrl(download_url_path), save_path, delayStart=True + ) + self._downloader.downloadError.connect(self._on_error_occurred) + self._downloader.downloadCanceled.connect(self._on_download_canceled) + self._downloader.downloadProgress.connect(self._on_progress_changed) + self._downloader.downloadCompleted.connect(self._on_download_completed) + + self._downloader.startDownload() + + return True + + +def get_downloader_task() -> typing.Optional[IrrecoverableCarbonDownloadTask]: + """Gets the irrecoverable carbon task downloader in the QgsTaskManager. + + :returns: The irrecoverable carbon task downloader in the QgsTaskManager + or None if not found. + :rtype: IrrecoverableCarbonDownloadTask + """ + ic_tasks = [ + task + for task in QgsApplication.taskManager().tasks() + if isinstance(task, IrrecoverableCarbonDownloadTask) + ] + if len(ic_tasks) == 0: + return None + + return ic_tasks[0] + + +def start_irrecoverable_carbon_download(): + """Starts the process of downloading the reference irrecoverable carbon dataset.""" + existing_download_task = get_downloader_task() + if existing_download_task: + existing_download_task.cancel() + + new_download_task = IrrecoverableCarbonDownloadTask() + QgsApplication.taskManager().addTask(new_download_task) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 9b5107a9d..679683657 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -545,8 +545,8 @@ def __init__(self, parent=None, main_widget=None) -> None: self.fw_save_online_file.setStorageMode(QgsFileWidget.StorageMode.SaveFile) self.fw_save_online_file.setFilter(tif_file_filter) - self.lbl_url_tip.setPixmap(FileUtils.get_pixmap("info_green.svg")) - self.lbl_url_tip.setScaledContents(True) + # self.lbl_url_tip.setPixmap(FileUtils.get_pixmap("info_green.svg")) + # self.lbl_url_tip.setScaledContents(True) self.btn_ic_download.setIcon(FileUtils.get_icon("downloading_svg.svg")) self.btn_ic_download.clicked.connect(self.on_download_irrecoverable_carbon) diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index c7d51975d..73faa40dd 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -16,6 +16,8 @@ QgsRectangle, ) +from qgis.PyQt import QtCore + from .base import ( BaseModelComponent, BaseModelComponentType, @@ -434,6 +436,26 @@ def extent_to_qgs_rectangle( ) +def extent_to_url_param(rect_extent: QgsRectangle) -> str: + """Converts the bounding box in a QgsRectangle object to the equivalent + param for use in a URL. 'bbox' is appended as a prefix in the URL query + part. + + :param rect_extent: Spatial extent that defines the AOI. + :type rect_extent: QgsRectangle + + :returns: String representing the param defining the extents of the AOI. + If the extent is empty, it will return an empty string. + :rtype: str + """ + if rect_extent.isEmpty(): + return "" + + extent_param = f"bbox={rect_extent.xMinimum()!s},{rect_extent.yMinimum()!s},{rect_extent.xMaximum()!s},{rect_extent.yMaximum()!s}" + + return QtCore.QUrl.toPercentEncoding(extent_param).data().decode("utf-8") + + def extent_to_project_crs_extent( spatial_extent: SpatialExtent, project: QgsProject = None ) -> typing.Union[QgsRectangle, None]: diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index 7f8bcbf19..915e81f66 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -10,7 +10,7 @@ 0 0 594 - 754 + 766 @@ -36,8 +36,8 @@ 0 0 - 572 - 732 + 555 + 768 @@ -326,7 +326,7 @@ true - + @@ -379,18 +379,23 @@ - - 2 - - - 2 - - - 2 - - - 2 - + + + + URL + + + + + + + Do not include the bbox PARAM, it will be automatically appended based on the current scenario extent + + + Specify the URL to fetch the dataset in the CI server + + + @@ -401,25 +406,12 @@ - - - - URL - - - - - - - - 0 - 0 - - + + - 24 - 24 + 16 + 16 @@ -428,9 +420,6 @@ 24 - - - QFrame::NoFrame @@ -439,23 +428,23 @@ - - - - Do not include the bbox PARAM, it will be automatically appended based on the current scenario extent + + + + QFrame::NoFrame - - Specify the URL to fetch the dataset in the CI server + + - - + + Initiate new download or refesh previous download in the background - ... + Start download From 493de0f601ce94f25e97ff37818461e54b50543b Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 14 Dec 2024 10:59:29 +0300 Subject: [PATCH 13/25] Fixes for downloading IC and updating UI. --- src/cplus_plugin/api/base.py | 26 ++ src/cplus_plugin/api/carbon.py | 274 ++++++++++++++++++ src/cplus_plugin/api/carbon_layers.py | 162 ----------- src/cplus_plugin/conf.py | 8 + src/cplus_plugin/gui/components/svg_label.py | 54 ++++ .../gui/settings/cplus_options.py | 113 +++++++- src/cplus_plugin/icons/mIndicatorTemporal.svg | 1 + src/cplus_plugin/icons/progress-indicator.svg | 23 ++ src/cplus_plugin/main.py | 22 ++ src/cplus_plugin/models/helpers.py | 8 +- src/cplus_plugin/ui/cplus_settings.ui | 118 +++++--- src/cplus_plugin/utils.py | 14 +- 12 files changed, 617 insertions(+), 206 deletions(-) create mode 100644 src/cplus_plugin/api/carbon.py delete mode 100644 src/cplus_plugin/api/carbon_layers.py create mode 100644 src/cplus_plugin/gui/components/svg_label.py create mode 100644 src/cplus_plugin/icons/mIndicatorTemporal.svg create mode 100644 src/cplus_plugin/icons/progress-indicator.svg diff --git a/src/cplus_plugin/api/base.py b/src/cplus_plugin/api/base.py index c17e63e32..d0af072f0 100644 --- a/src/cplus_plugin/api/base.py +++ b/src/cplus_plugin/api/base.py @@ -6,6 +6,7 @@ import concurrent.futures import copy import datetime +from enum import IntEnum import json import os @@ -207,3 +208,28 @@ def fetch_scenario_output( analysis_output=final_output, ) return scenario, scenario_result + + +class ApiRequestStatus(IntEnum): + """Status of API request.""" + + NOT_STARTED = 0 + IN_PROGRESS = 1 + COMPLETED = 2 + ERROR = 3 + CANCELED = 4 + + @staticmethod + def from_int(status: int) -> "ApiRequestStatus": + """Gets the status from an int value. + + :returns: The status from an int value. + :rtype: int + """ + return { + 0: ApiRequestStatus.NOT_STARTED, + 1: ApiRequestStatus.IN_PROGRESS, + 2: ApiRequestStatus.COMPLETED, + 3: ApiRequestStatus.ERROR, + 4: ApiRequestStatus.CANCELED, + }[status] diff --git a/src/cplus_plugin/api/carbon.py b/src/cplus_plugin/api/carbon.py new file mode 100644 index 000000000..396a0fccd --- /dev/null +++ b/src/cplus_plugin/api/carbon.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +""" +API requests for managing carbon layers. +""" + +from datetime import datetime +import typing + +from qgis.core import QgsApplication, QgsFileDownloader, QgsRectangle, QgsTask +from qgis.PyQt import QtCore + +from .base import ApiRequestStatus +from ..conf import settings_manager, Settings +from ..models.helpers import extent_to_url_param +from ..utils import log, tr + + +class IrrecoverableCarbonDownloadTask(QgsTask): + """Task for downloading the irrecoverable carbon dataset from the + online server. + + The required information i.e. download URL, file path for saving + the downloaded file and extents for clipping the dataset are fetched + from the settings hence they need to be defined for the download + process to be successfully initiated. + """ + + error_occurred = QtCore.pyqtSignal() + canceled = QtCore.pyqtSignal() + completed = QtCore.pyqtSignal() + started = QtCore.pyqtSignal() + + def __init__(self): + super().__init__(tr("Downloading irrecoverable carbon dataset")) + self._downloader = None + self._event_loop = None + self._errors = None + + @property + def errors(self) -> typing.List[str]: + """Gets any errors encountered during the download process. + + :returns: Download errors. + :rtype: typing.List[str] + """ + return [] if self._errors is None else self._errors + + def cancel(self): + """Cancel the download process.""" + if self._downloader: + self._downloader.cancelDownload() + self._update_download_status(ApiRequestStatus.CANCELED, "Download canceled") + self.disconnect_receivers() + + self._event_loop.quit() + + super().cancel() + + log("Irrecoverable carbon dataset task canceled.") + + def _on_error_occurred(self, error_messages: typing.List[str]): + """Slot raised when the downloader encounters an error. + + :param error_messages: Error messages. + :type error_messages: typing.List[str] + """ + self._errors = error_messages + + err_msg = ", ".join(error_messages) + log(f"Error in downloading irrecoverable carbon dataset: {err_msg}", info=False) + + self._update_download_status( + ApiRequestStatus.ERROR, tr("Download error. See logs for details.") + ) + + self._event_loop.quit() + + self.error_occurred.emit() + + def _on_download_canceled(self): + """Slot raised when the download has been canceled.""" + log("Download of irrecoverable carbon dataset canceled.") + + self._event_loop.quit() + + self.canceled.emit() + + def _on_download_completed(self, url: QtCore.QUrl): + """Slot raised when the download is complete. + + :param url: Url of the file resource. + :type url: QtCore.QUrl + """ + completion_datetime_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + log( + f"Download of irrecoverable carbon dataset successfully completed " + f"on {completion_datetime_str}." + ) + + self._update_download_status( + ApiRequestStatus.COMPLETED, tr("Download successful") + ) + + self._event_loop.quit() + + self.completed.emit() + + def _on_progress_changed(self, received: int, total: int): + """Slot raised indicating progress made by the downloader. + + :param received: Bytes received. + :type received: int + + :param total: Total size of the file in bytes. + :type total: int + """ + total_float = float(total) + if total_float == 0.0: + self.setProgress(total_float) + else: + self.setProgress(received / total_float * 100) + + def _update_download_status(self, status: ApiRequestStatus, description: str): + """Updates the settings with the online download status. + + :param status: Download status to save. + :type status: ApiRequestStatus + + :param description: Brief description of the status. + :type description: str + """ + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_DOWNLOAD_STATUS, status.value + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_STATUS_DESCRIPTION, description + ) + + def disconnect_receivers(self): + """Disconnects all custom signals related to the downloader. This is + recommended prior to canceling the task. + """ + self._downloader.downloadError.disconnect(self._on_error_occurred) + self._downloader.downloadCanceled.disconnect(self._on_download_canceled) + self._downloader.downloadProgress.disconnect(self._on_progress_changed) + self._downloader.downloadCompleted.disconnect(self._on_download_completed) + + def run(self) -> bool: + """Initiates the report generation process and returns + a result indicating whether the process succeeded or + failed. + + :returns: True if the report generation process succeeded + or False it if failed. + :rtype: bool + """ + if self.isCanceled(): + return False + + # Get extents, URL and local path + extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None) + if extent is None: + log( + "Scenario extent not defined for downloading irrecoverable " + "carbon dataset.", + info=False, + ) + return False + + if len(extent) < 4: + log( + "Definition of scenario extent is incorrect. Consists of " + "less than 4 segments.", + info=False, + ) + return False + + extent_rectangle = QgsRectangle( + float(extent[0]), float(extent[2]), float(extent[1]), float(extent[3]) + ) + url_bbox_part = extent_to_url_param(extent_rectangle) + if not url_bbox_part: + log( + "Unable to create the bbox query part of the irrecoverable " + "carbon download URL.", + info=False, + ) + return False + + base_download_url_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="", setting_type=str + ) + if not base_download_url_path: + log("Source URL for irrecoverable carbon dataset not found.", info=False) + return False + + full_download_url = QtCore.QUrl(base_download_url_path) + full_download_url.setQuery(url_bbox_part) + + save_path = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + default="", + setting_type=str, + ) + if not save_path: + log( + "Save location for irrecoverable carbon dataset not specified.", + info=False, + ) + return False + + # Use to block downloader until it finishes or encounters an error + self._event_loop = QtCore.QEventLoop(self) + + self._downloader = QgsFileDownloader( + full_download_url, save_path, delayStart=True + ) + self._downloader.downloadError.connect(self._on_error_occurred) + self._downloader.downloadCanceled.connect(self._on_download_canceled) + self._downloader.downloadProgress.connect(self._on_progress_changed) + self._downloader.downloadCompleted.connect(self._on_download_completed) + + self._update_download_status( + ApiRequestStatus.NOT_STARTED, tr("Download not started") + ) + + self._downloader.startDownload() + + self.started.emit() + + self._update_download_status( + ApiRequestStatus.IN_PROGRESS, tr("Download ongoing") + ) + + log( + f"Started download of irrecoverable carbon dataset - {full_download_url.toString()} - " + f"on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + + self._event_loop.exec_() + + return True + + +def get_downloader_task() -> typing.Optional[IrrecoverableCarbonDownloadTask]: + """Gets the irrecoverable carbon task downloader in the QgsTaskManager. + + :returns: The irrecoverable carbon task downloader in the QgsTaskManager + or None if not found. + :rtype: IrrecoverableCarbonDownloadTask + """ + ic_tasks = [ + task + for task in QgsApplication.taskManager().tasks() + if isinstance(task, IrrecoverableCarbonDownloadTask) + ] + if len(ic_tasks) == 0: + return None + + return ic_tasks[0] + + +def start_irrecoverable_carbon_download(): + """Starts the process of downloading the reference irrecoverable carbon dataset. + + Any ongoing downloading processing will be canceled. + """ + existing_download_task = get_downloader_task() + if existing_download_task: + existing_download_task.cancel() + + new_download_task = IrrecoverableCarbonDownloadTask() + QgsApplication.taskManager().addTask(new_download_task) diff --git a/src/cplus_plugin/api/carbon_layers.py b/src/cplus_plugin/api/carbon_layers.py deleted file mode 100644 index a64e2f5e5..000000000 --- a/src/cplus_plugin/api/carbon_layers.py +++ /dev/null @@ -1,162 +0,0 @@ -# -*- coding: utf-8 -*- -""" -API requests for managing carbon layers. -""" -from numbers import Number -import os -from pathlib import Path -import traceback -import typing - -from qgis.core import QgsApplication, QgsFileDownloader, QgsRectangle, QgsTask -from qgis.PyQt import QtCore - -from ..conf import settings_manager, Settings -from ..models.helpers import extent_to_url_param -from ..utils import log, tr - - -class IrrecoverableCarbonDownloadTask(QgsTask): - """Class for downloading the irrecoverable carbon data from the online server.""" - - error_occurred = QtCore.pyqtSignal(list) - canceled = QtCore.pyqtSignal() - completed = QtCore.pyqtSignal() - - def __init__(self): - super().__init__(tr("Downloading irrecoverable carbon dataset")) - self._downloader = None - - def cancel(self): - """Cancel the download process.""" - if self._downloader: - self._downloader.cancelDownload() - - super().cancel() - - log("Irrecoverable carbon dataset task canceled.") - - def _on_error_occurred(self, error_messages: typing.List[str]): - """Slot raised when the downloader encounters an error. - - :param error_messages: Error messages. - :type error_messages: typing.List[str] - """ - self.error_occurred.emit(error_messages) - - err_msg = ", ".join(error_messages) - log(f"Error in downloading irrecoverable carbon dataset: {err_msg}", info=False) - - def _on_download_canceled(self): - """Slot raised when the download has been canceled.""" - self.canceled.emit() - - log("Download of irrecoverable carbon dataset canceled.") - - def _on_download_completed(self, url: QtCore.QUrl): - """Slot raised when the download is complete. - - :param url: Url of the file resource. - :type url: QtCore.QUrl - """ - self.completed.emit() - - log("Download of irrecoverable carbon dataset successfully completed.") - - def _on_progress_changed(self, received: int, total: int): - """Slot raised indicating progress made by the downloader. - - :param received: Bytes received. - :type received: int - - :param total: Total size of the file in bytes. - :type total: int - """ - self.setProgress(received / float(total) * 100) - - def run(self) -> bool: - """Initiates the report generation process and returns - a result indicating whether the process succeeded or - failed. - - :returns: True if the report generation process succeeded - or False it if failed. - :rtype: bool - """ - if self.isCanceled(): - return False - - # Get extents, URL and local path - extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None) - if extent is None: - if not extent: - log( - "Scenario extent not defined for downloading irrecoverable " - "carbon dataset.", - info=False, - ) - return False - - extent_rectangle = QgsRectangle( - float(extent[0]), float(extent[2]), float(extent[1]), float(extent[3]) - ) - url_bbox_part = extent_to_url_param(extent_rectangle) - - download_url_path = settings_manager.get_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="", setting_type=str - ) - if not download_url_path: - log("Source URL for irrecoverable carbon dataset not found.", info=False) - return False - - save_path = settings_manager.get_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, - default="", - setting_type=str, - ) - if not save_path: - log( - "Save location for irrecoverable carbon dataset not specified.", - info=False, - ) - return False - - self._downloader = QgsFileDownloader( - QtCore.QUrl(download_url_path), save_path, delayStart=True - ) - self._downloader.downloadError.connect(self._on_error_occurred) - self._downloader.downloadCanceled.connect(self._on_download_canceled) - self._downloader.downloadProgress.connect(self._on_progress_changed) - self._downloader.downloadCompleted.connect(self._on_download_completed) - - self._downloader.startDownload() - - return True - - -def get_downloader_task() -> typing.Optional[IrrecoverableCarbonDownloadTask]: - """Gets the irrecoverable carbon task downloader in the QgsTaskManager. - - :returns: The irrecoverable carbon task downloader in the QgsTaskManager - or None if not found. - :rtype: IrrecoverableCarbonDownloadTask - """ - ic_tasks = [ - task - for task in QgsApplication.taskManager().tasks() - if isinstance(task, IrrecoverableCarbonDownloadTask) - ] - if len(ic_tasks) == 0: - return None - - return ic_tasks[0] - - -def start_irrecoverable_carbon_download(): - """Starts the process of downloading the reference irrecoverable carbon dataset.""" - existing_download_task = get_downloader_task() - if existing_download_task: - existing_download_task.cancel() - - new_download_task = IrrecoverableCarbonDownloadTask() - QgsApplication.taskManager().addTask(new_download_task) diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 5a3c241f3..7557a695b 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -261,6 +261,14 @@ class Settings(enum.Enum): IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH = ( "carbon/irrecoverable_carbon_online_local_dir" ) + # Download status of the IC layer - int enum + IRRECOVERABLE_CARBON_ONLINE_DOWNLOAD_STATUS = ( + "carbon/irrecoverable_carbon_online_download_status" + ) + # Brief description of download status + IRRECOVERABLE_CARBON_ONLINE_STATUS_DESCRIPTION = ( + "carbon/irrecoverable_carbon_online_status_description" + ) class SettingsManager(QtCore.QObject): diff --git a/src/cplus_plugin/gui/components/svg_label.py b/src/cplus_plugin/gui/components/svg_label.py new file mode 100644 index 000000000..f638973ca --- /dev/null +++ b/src/cplus_plugin/gui/components/svg_label.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +Custom label for displaying SVG images. +""" + +import os +import typing + +from qgis.PyQt import QtCore, QtGui, QtSvg, QtWidgets + + +class SvgLabel(QtWidgets.QLabel): + """Label for displaying an SVG image.""" + + def __init__(self, parent=None, svg_path=None): + super().__init__(parent) + self._svg_path = svg_path + self._update() + + @property + def svg_path(self) -> str: + """Gets the path to the SVG file. + + :returns: The path to the SVG file or an empty + string if not specified. + :rtype: str + """ + return self._svg_path + + @svg_path.setter + def svg_path(self, svg_path: str): + """Sets the path to the SVG file. + + :param svg_path: Path to the SVG file. If empty, + the label will display a blank image. + :type svg_path: str + """ + if svg_path != self._svg_path: + self._svg_path = svg_path + self._update() + + def _update(self): + """Render the SVG image.""" + pixmap = QtGui.QPixmap() + + if self._svg_path: + renderer = QtSvg.QSvgRenderer(os.path.normpath(self._svg_path)) + pixmap = QtGui.QPixmap(renderer.defaultSize()) + pixmap.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(pixmap) + renderer.render(painter) + painter.end() + + self.setPixmap(pixmap) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index 679683657..ad7f6b71e 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -27,6 +27,11 @@ from qgis.PyQt.QtWidgets import QFileDialog, QListWidgetItem, QMessageBox, QWidget from qgis.PyQt import QtCore +from ...api.base import ApiRequestStatus +from ...api.carbon import ( + start_irrecoverable_carbon_download, + get_downloader_task, +) from ...conf import ( settings_manager, Settings, @@ -551,6 +556,10 @@ def __init__(self, parent=None, main_widget=None) -> None: self.btn_ic_download.setIcon(FileUtils.get_icon("downloading_svg.svg")) self.btn_ic_download.clicked.connect(self.on_download_irrecoverable_carbon) + # Use the task to get real time updates on the download progress + self._irrecoverable_carbon_downloader = None + self._configure_irrecoverable_carbon_downloader_updates() + # Load gui default value from settings auth_id = settings.value("cplusplugin/auth") if auth_id is not None: @@ -824,6 +833,63 @@ def load_settings(self) -> None: self.validate_current_irrecoverable_data_source() + self.reload_irrecoverable_carbon_download_status() + + def reload_irrecoverable_carbon_download_status(self): + """Fetch the latest download status of the irrecoverable carbon + dataset from the online source if applicable. + """ + status = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_DOWNLOAD_STATUS, None, int + ) + if status is None: + return + + # Set notification icon + path = "" + status_type = ApiRequestStatus.from_int(status) + if status_type == ApiRequestStatus.COMPLETED: + path = FileUtils.get_icon_path("mIconSuccess.svg") + elif status_type == ApiRequestStatus.ERROR: + path = FileUtils.get_icon_path("mIconWarning.svg") + elif status_type == ApiRequestStatus.NOT_STARTED: + path = FileUtils.get_icon_path("mIndicatorTemporal.svg") + elif status_type == ApiRequestStatus.IN_PROGRESS: + path = FileUtils.get_icon_path("progress-indicator.svg") + elif status_type == ApiRequestStatus.CANCELED: + path = FileUtils.get_icon_path("mTaskCancel.svg") + + self.lbl_download_status_tip.svg_path = path + + # Set notification description + description = settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_STATUS_DESCRIPTION, "", str + ) + self.lbl_ic_download_status.setText(description) + + def _configure_irrecoverable_carbon_downloader_updates(self): + """Get current downloader and connect the signals of the + task in order to update the UI. + """ + # Use the task to get real time updates on the download progress + self._irrecoverable_carbon_downloader = get_downloader_task() + + if self._irrecoverable_carbon_downloader is None: + return + + self._irrecoverable_carbon_downloader.started.connect( + self.reload_irrecoverable_carbon_download_status + ) + self._irrecoverable_carbon_downloader.canceled.connect( + self.reload_irrecoverable_carbon_download_status + ) + self._irrecoverable_carbon_downloader.completed.connect( + self.reload_irrecoverable_carbon_download_status + ) + self._irrecoverable_carbon_downloader.error_occurred.connect( + self.reload_irrecoverable_carbon_download_status + ) + def validate_irrecoverable_carbon_url(self) -> bool: """Checks if the irrecoverable data URL is valid. @@ -837,6 +903,7 @@ def validate_irrecoverable_carbon_url(self) -> bool: self.message_bar.pushWarning( tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined") ) + return False url_checker = QtCore.QUrl(dataset_url, QtCore.QUrl.StrictMode) if url_checker.isLocalFile(): @@ -844,25 +911,65 @@ def validate_irrecoverable_carbon_url(self) -> bool: tr("CPLUS - Irrecoverable carbon dataset"), tr("Invalid URL referencing a local file"), ) + return False else: if not url_checker.isValid(): self.message_bar.pushWarning( tr("CPLUS - Irrecoverable carbon dataset"), tr("URL is invalid."), ) + return False + + return True def on_download_irrecoverable_carbon(self): """Slot raised to check download link and initiate download process of the irrecoverable carbon data. + + The function will check and save the currently defined local + save as path for the reference dataset as this will be required + and fetched by the background download process. + """ valid_url = self.validate_irrecoverable_carbon_url() if not valid_url: - log( - tr("Link for downloading irrecoverable carbon data is invalid."), - info=False, + tr_title = tr("CPLUS - Online irrecoverable carbon dataset") + tr_msg = tr("URL for downloading irrecoverable carbon data is invalid.") + self.message_bar.pushWarning(tr_title, tr_msg) + + return + + if not self.fw_save_online_file.filePath(): + tr_title = tr("CPLUS - Online irrecoverable carbon dataset") + tr_msg = tr( + "File path for saving downloaded irrecoverable " + "carbon dataset not defined" ) + self.message_bar.pushWarning(tr_title, tr_msg) + return + # Check if the local path has been saved in settings or varies from + # what already is saved in settings + download_save_path = self.fw_save_online_file.filePath() + if ( + settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + default="", + setting_type=str, + ) + != download_save_path + ): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, download_save_path + ) + + # (Re)initiate download + start_irrecoverable_carbon_download() + + # Get downloader for UI updates + self._configure_irrecoverable_carbon_downloader_updates() + def validate_current_irrecoverable_data_source(self): """Checks if the currently selected irrecoverable data source is valid. diff --git a/src/cplus_plugin/icons/mIndicatorTemporal.svg b/src/cplus_plugin/icons/mIndicatorTemporal.svg new file mode 100644 index 000000000..bb55b16e9 --- /dev/null +++ b/src/cplus_plugin/icons/mIndicatorTemporal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/icons/progress-indicator.svg b/src/cplus_plugin/icons/progress-indicator.svg new file mode 100644 index 000000000..094506960 --- /dev/null +++ b/src/cplus_plugin/icons/progress-indicator.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/src/cplus_plugin/main.py b/src/cplus_plugin/main.py index 6476dc05e..6bec3d483 100644 --- a/src/cplus_plugin/main.py +++ b/src/cplus_plugin/main.py @@ -31,6 +31,7 @@ from qgis.PyQt.QtWidgets import QToolButton from qgis.PyQt.QtWidgets import QMenu +from .api.base import ApiRequestStatus from .conf import Settings, settings_manager from .definitions.defaults import ( ABOUT_DOCUMENTATION_SITE, @@ -62,6 +63,7 @@ log, open_documentation, get_plugin_version, + tr, ) @@ -465,6 +467,8 @@ def initialize_api_url(): settings_manager.set_value(Settings.DEBUG, False) if not settings_manager.get_value(Settings.BASE_API_URL, None, str): settings_manager.set_value(Settings.BASE_API_URL, BASE_API_URL) + + # Default URL for irrecoverable carbon dataset if not settings_manager.get_value( Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, None, str ): @@ -472,6 +476,24 @@ def initialize_api_url(): Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL ) + # Default status of downloading irrecoverable carbon dataset + if not settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_DOWNLOAD_STATUS, None, int + ): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_DOWNLOAD_STATUS, + ApiRequestStatus.NOT_STARTED.value, + ) + + # Default description of irrecoverable carbon dataset download status + if not settings_manager.get_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_STATUS_DESCRIPTION, None, str + ): + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_STATUS_DESCRIPTION, + tr("Download not started"), + ) + def initialize_report_settings(): """Sets the default report settings on first time use diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index 73faa40dd..946211da3 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -451,9 +451,13 @@ def extent_to_url_param(rect_extent: QgsRectangle) -> str: if rect_extent.isEmpty(): return "" - extent_param = f"bbox={rect_extent.xMinimum()!s},{rect_extent.yMinimum()!s},{rect_extent.xMaximum()!s},{rect_extent.yMaximum()!s}" + url_query = QtCore.QUrlQuery() + url_query.addQueryItem( + "bbox", + f"{rect_extent.xMinimum()!s},{rect_extent.yMinimum()!s},{rect_extent.xMaximum()!s},{rect_extent.yMaximum()!s}", + ) - return QtCore.QUrl.toPercentEncoding(extent_param).data().decode("utf-8") + return url_query.toString() def extent_to_project_crs_extent( diff --git a/src/cplus_plugin/ui/cplus_settings.ui b/src/cplus_plugin/ui/cplus_settings.ui index 915e81f66..0d79d468c 100644 --- a/src/cplus_plugin/ui/cplus_settings.ui +++ b/src/cplus_plugin/ui/cplus_settings.ui @@ -10,7 +10,7 @@ 0 0 594 - 766 + 795 @@ -36,8 +36,8 @@ 0 0 - 555 - 768 + 572 + 773 @@ -327,6 +327,18 @@ true + + 6 + + + 6 + + + 6 + + + 6 + @@ -352,6 +364,12 @@ + + + 0 + 0 + + 1 @@ -378,7 +396,19 @@ - + + + 5 + + + 5 + + + 5 + + + 5 + @@ -386,7 +416,7 @@ - + Do not include the bbox PARAM, it will be automatically appended based on the current scenario extent @@ -403,41 +433,9 @@ - + - - - - - 16 - 16 - - - - - 24 - 24 - - - - QFrame::NoFrame - - - - - - - - - - QFrame::NoFrame - - - - - - @@ -448,6 +446,45 @@ + + + + 4 + + + + + + 16 + 16 + + + + + 24 + 24 + + + + QFrame::NoFrame + + + + + + + + + + QFrame::NoFrame + + + + + + + + @@ -624,6 +661,11 @@
qgis.gui
1 + + SvgLabel + QLabel +
cplus_plugin.gui.components.svg_label
+
diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index b2a6d9873..ffe473447 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -447,6 +447,18 @@ def get_fonts_dir() -> str: """ return f"{FileUtils.plugin_dir()}/data/fonts" + @staticmethod + def get_icon_path(file_name: str) -> str: + """Gets the full path of the icon with the given name. + + :param file_name: File name which should include the extension. + :type file_name: str + + :returns: The full path to the icon in the plugin. + :rtype: str + """ + return os.path.normpath(f"{FileUtils.plugin_dir()}/icons/{file_name}") + @staticmethod def get_icon(file_name: str) -> QtGui.QIcon: """Creates an icon based on the icon name in the 'icons' folder. @@ -457,7 +469,7 @@ def get_icon(file_name: str) -> QtGui.QIcon: :returns: Icon object matching the file name. :rtype: QtGui.QIcon """ - icon_path = os.path.normpath(f"{FileUtils.plugin_dir()}/icons/{file_name}") + icon_path = FileUtils.get_icon_path(file_name) if not os.path.exists(icon_path): return QtGui.QIcon() From 9504f5c17915b1cb78f7236a47b3198aebe7c84a Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sun, 15 Dec 2024 23:36:39 +0300 Subject: [PATCH 14/25] Incorporate IC unit tests. --- src/cplus_plugin/api/carbon.py | 11 ++-- src/cplus_plugin/lib/carbon.py | 2 + src/cplus_plugin/lib/reports/generator.py | 2 +- src/cplus_plugin/lib/validation/validators.py | 2 +- src/cplus_plugin/models/base.py | 25 ++++---- .../carbon/layers/irrecoverable_carbon.tif | Bin 0 -> 750 bytes .../layers/protected_test_pathway_1.tif | Bin 0 -> 2488 bytes .../layers/protected_test_pathway_2.tif | Bin 0 -> 2368 bytes test/model_data_for_testing.py | 59 ++++++++++++++++++ test/test_metrics.py | 44 +++++++++++++ 10 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 test/data/carbon/layers/irrecoverable_carbon.tif create mode 100644 test/data/pathways/layers/protected_test_pathway_1.tif create mode 100644 test/data/pathways/layers/protected_test_pathway_2.tif diff --git a/src/cplus_plugin/api/carbon.py b/src/cplus_plugin/api/carbon.py index 396a0fccd..0e2ed0060 100644 --- a/src/cplus_plugin/api/carbon.py +++ b/src/cplus_plugin/api/carbon.py @@ -147,12 +147,11 @@ def disconnect_receivers(self): self._downloader.downloadCompleted.disconnect(self._on_download_completed) def run(self) -> bool: - """Initiates the report generation process and returns - a result indicating whether the process succeeded or - failed. + """Initiates the download of irrecoverable carbon dataset process and + returns a result indicating whether the process succeeded or failed. - :returns: True if the report generation process succeeded - or False it if failed. + :returns: True if the download process succeeded or False it if + failed. :rtype: bool """ if self.isCanceled(): @@ -210,7 +209,7 @@ def run(self) -> bool: ) return False - # Use to block downloader until it finishes or encounters an error + # Use to block downloader until it completes or encounters an error self._event_loop = QtCore.QEventLoop(self) self._downloader = QgsFileDownloader( diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 585b31d23..e68e659ae 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -340,6 +340,8 @@ def run(self) -> float: protect_data_sources = [layer.source() for layer in valid_protect_layers] + # First merge the protect NCS pathways into one raster + boolean_args = { "INPUT": protect_data_sources, "REF_LAYER": protect_data_sources[0], diff --git a/src/cplus_plugin/lib/reports/generator.py b/src/cplus_plugin/lib/reports/generator.py index 736131d36..84c172b6c 100644 --- a/src/cplus_plugin/lib/reports/generator.py +++ b/src/cplus_plugin/lib/reports/generator.py @@ -277,7 +277,7 @@ class ScenarioComparisonReportGeneratorTask(BaseScenarioReportGeneratorTask): def __init__(self, description: str, context: ScenarioComparisonReportContext): super().__init__(description, context) self._generator = ScenarioComparisonReportGenerator( - context, self._context.feedback + self, context, self._context.feedback ) def finished(self, result: bool): diff --git a/src/cplus_plugin/lib/validation/validators.py b/src/cplus_plugin/lib/validation/validators.py index 1c5d19cbc..36ef1358a 100644 --- a/src/cplus_plugin/lib/validation/validators.py +++ b/src/cplus_plugin/lib/validation/validators.py @@ -601,7 +601,7 @@ def is_comparative(self) -> bool: class ResolutionValidator(BaseRuleValidator): """Checks if datasets have the same spatial resolution.""" - DECIMAL_PLACES = 6 + DECIMAL_PLACES = 3 def _validate(self) -> bool: """Checks whether input datasets have the same diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py index de905320f..f2de00735 100644 --- a/src/cplus_plugin/models/base.py +++ b/src/cplus_plugin/models/base.py @@ -297,14 +297,12 @@ def from_int(int_enum: int) -> "NcsPathwayType": integer else unknown if not found. :rtype: NcsPathwayType """ - if int_enum == 0: - return NcsPathwayType.PROTECT - elif int_enum == 1: - return NcsPathwayType.RESTORE - elif int_enum == 2: - return NcsPathwayType.MANAGE - else: - return NcsPathwayType.UNDEFINED + return { + 0: NcsPathwayType.PROTECT, + 1: NcsPathwayType.RESTORE, + 2: NcsPathwayType.MANAGE, + -1: NcsPathwayType.UNDEFINED, + }[int_enum] @dataclasses.dataclass @@ -714,9 +712,8 @@ def from_int(int_enum: int) -> "DataSourceType": integer else unknown if not found. :rtype: DataSourceType """ - if int_enum == 0: - return DataSourceType.LOCAL - elif int_enum == 1: - return DataSourceType.ONLINE - else: - return DataSourceType.UNDEFINED + return { + 0: DataSourceType.LOCAL, + 1: DataSourceType.ONLINE, + -1: DataSourceType.UNDEFINED, + }[int_enum] diff --git a/test/data/carbon/layers/irrecoverable_carbon.tif b/test/data/carbon/layers/irrecoverable_carbon.tif new file mode 100644 index 0000000000000000000000000000000000000000..4d622c5843bb2e86c7834dee22f22279ad9cfffc GIT binary patch literal 750 zcmebD)M6-OWMB|rU|?is05TYufS3`9%>-owRWL9^*&uanNNhny7O-BRFoP(PxEPcT zGE*E$jSP~SU?etQGY=TA z2T(i>F~~6p0*#?OwDW)tA{5%7&>C~_{$eWp(8k;fz0GuRJA^-pY literal 0 HcmV?d00001 diff --git a/test/data/pathways/layers/protected_test_pathway_1.tif b/test/data/pathways/layers/protected_test_pathway_1.tif new file mode 100644 index 0000000000000000000000000000000000000000..2c4ce9788502399f5cba29d15fe3622c61c76761 GIT binary patch literal 2488 zcmdUxe^69a6vywv%C7*i2C|WCjg$cl9Cl?^C%<5dU|^qbyWB5*Hu41FMX6o>w1=&Nw}9iqF-0z zJniKZ>E)BvulrJ;(DGoS$`-mdZVD!TRQ^m?LxnHVQ5i(RO%*<5EY)98lc3jyfRJ8B z(x)6be4MaUx{QzcidZ&hr!^yPdUa-aacFb)^hd55R|WorXlecGr(gPLn<^rxlO~|4 z!kyj~13(~T5MuRRv_r~jUbn6RO_ ziySZ)*)d~{4H0{7sBW;LudlRk`3(!!IqMMHRg1RRI=nZr7Gk^^)AP+JC{Qr9QZNJjs;;h zbf?+yTDqIrT4r(iy|lR&&0p2RaNLZ=rxdhn%oyRX;9#H(L#m8x=@O>R5pn8M5z*TO zBvtV^f1XECD+j+m4pVwrbl+iIvD8{xexmh_bf7!Lj>#dk#+f!8ooPjBss*hj7L=CO zxqQbQuEnyWW-M({(DXpTevJZXW$a3p@!SjvS!p7Ul#6)qi2!FMkL7217{24Mi86~Q z)9J;`SZ7CNgB|1c*pTY5qTs3(t2!(Q>u|H0pK(hyzsNVua4u93v{S*ayJh4t5~|uI z#9tP%;i`ad#)|mkc>#x_d5p>7aeo6XzkQNi{E8O}rrx0XxDUM1q=wF1O_0xo~Sqx~3%$cMD2 zejIvIILtM&7&pnonDp zU~#X2#mUtSeqT8_(>wO#^qgE7OLHZRUMk`pNgT*Owu8sF*EmFVvba8;Ltz|?;CvRx z3K{sX86H#3=S+|^z*2vIFpI~zG9KxhIFwYd(4AmWdYZw*5iBa7U{F75_{_oj5IKQI zaWsb=F)T*rvY408ps~)qcg8f<{3pl3o*&BA*+l3P|YW2jUgdp1vK;;=sFf z@8mLwFf+)vnvlL{_{`8nBJ|k;>URmawZp?~Jruw5H#Anu$3{%npjhRw3VbU=)XHuqy$v~5u zNy^mSd+z75?1y6!(qQ?W-ydk=2MQx5GLlx~3@I|2qIB-vCD#5(nf~hToPD1?=e_&8 z?>Xn5CnF<)>^2k9mk@)A7ziOlSTh7P>m|m3wlHl9Xe}=^NJkk$^m|!185qRwBnEJt}deyn+Oa(AjhuYgTO2&-TF1fnsdx-^<48N#Yd z%_N$HhH2wv@g!1<&8tH6I41s$saY0lvgLWp?Ccqq50@>?w{R1(iu}Drq^oOHS8rQ< ztrHIpswmm3pwArz+Wm2D5{8eFv1gQoXGV%}&KHq7U%+WeK($N}t@3(aZ&~@Pok%WH zQMp1vN2Q;2yF$YLpCx2p6ya(RU>YD|QQZ{X2Z(R-HfWhX{WR56QF&~J&KwRWC_ zD-H>%8$@J%Az)6efc-})t}&L|Nbw6}bu8^Yt0lyV-1`bXy(NPrC|EFD#)=`5K0kkC zih68bBVcAR#p`>h{`>764tOW|Fu0ez*5j&v|Cifj?7bmlM5_ebB@uB0{k(s!e{ms? z%kMidbE*$>3!l8UJgUNTO2xT@3clST>&$PiODJg;F*N2;)?FKgyNIpT2Rww84&*%R z!~KjWV}-ouM4yE!+8ruJf36^`SO)z;#=c4ks$0ZeRvUMo?S-KCC>#DcEKYGC=B5`j zEk0yE<$WY4I6qi_mZV}}zKYp73T)G5tSXUFQz*e*AY!{mgjp64yNT_;IzOY+L~-|_ z1IzE(v9&Qh39^5D!A zVMZRg5A0}Y@PZ5Vq2Z?Iv2|s~usIA@QJ<#b&^Z5IZ=ch{iYpY6ULe4lM{$gu6&ioi z86LJ14jevg$Kmh2*xKqvL7fNEPPab(-T4Zn9dKdYEf@M;DaWu=PPBA65z(gN^koHH ze-)fb!L{2GViIJGjO*DG1(O7<<|rE9r6|hdF)eU5*}k%4?jbMg*_sW#`yNI+37;~@8y5jTXjc8_;ps#U==iivF=OQo+M*&d=E>jrOZmqX=q5wN~fKvNrqv6aWhSc+x+d2E@^ zBYlbk@1)t$j~UQ^_v}T?M<3_e+02L^=h-F97rQ-MV7lA0Ynkr$?1*qeirq#M!~AqK h)7tg_@bBliamh*Jt>d}mloZZ7VZ7CvlAp*~$zRe7Q>_31 literal 0 HcmV?d00001 diff --git a/test/model_data_for_testing.py b/test/model_data_for_testing.py index 613de00b1..a8007c796 100644 --- a/test/model_data_for_testing.py +++ b/test/model_data_for_testing.py @@ -15,6 +15,7 @@ DESCRIPTION_ATTRIBUTE, LAYER_TYPE_ATTRIBUTE, PATH_ATTRIBUTE, + PATHWAY_TYPE_ATTRIBUTE, PRIORITY_LAYERS_SEGMENT, USER_DEFINED_ATTRIBUTE, UUID_ATTRIBUTE, @@ -24,6 +25,7 @@ Activity, LayerType, NcsPathway, + NcsPathwayType, Scenario, ScenarioResult, SpatialExtent, @@ -54,6 +56,9 @@ NCS_UUID_STR_2 = "424e076e-61b7-4116-a5a9-d2a7b4c2574e" NCS_UUID_STR_3 = "5c42b644-3d21-4081-9206-28e872efca73" +PROTECTED_NCS_UUID_STR_1 = "2ac8de1d-2181-4f84-83b5-fbe6e600a3ab" +PROTECTED_NCS_UUID_STR_2 = "f9c7b0a2-dc35-4d40-aa1f-c871f06e7da7" + ACTIVITY_1_NPV = 40410.23 @@ -67,6 +72,7 @@ def get_valid_ncs_pathway() -> NcsPathway: LayerType.RASTER, True, carbon_paths=[], + pathway_type=NcsPathwayType.MANAGE, ) @@ -171,6 +177,58 @@ def get_ncs_pathways(use_projected=False) -> typing.List[NcsPathway]: return [ncs_pathway1, ncs_pathway2, ncs_pathway3] +def get_protected_ncs_pathways() -> typing.List[NcsPathway]: + """Returns a list of protected NCS pathways. + + :returns: List of protected NCS pathways. + :rtype: list + """ + pathway_layer_directory = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "pathways", "layers" + ) + + filenames = [] + for i in range(1, 3): + base_name = "protected_test_pathway" + filenames.append(f"{base_name}_{i!s}.tif") + + protected_pathway_layer_path1 = os.path.join(pathway_layer_directory, filenames[0]) + protected_pathway_layer_path2 = os.path.join(pathway_layer_directory, filenames[1]) + + protected_ncs_pathway1 = NcsPathway( + UUID(PROTECTED_NCS_UUID_STR_1), + "Protected NCS One", + "Description for Protected NCS one", + protected_pathway_layer_path1, + LayerType.RASTER, + True, + pathway_type=NcsPathwayType.PROTECT, + ) + + protected_ncs_pathway2 = NcsPathway( + UUID(PROTECTED_NCS_UUID_STR_2), + "Protected NCS Two", + "Description for Protected NCS two", + protected_pathway_layer_path2, + LayerType.RASTER, + True, + pathway_type=NcsPathwayType.PROTECT, + ) + + return [protected_ncs_pathway1, protected_ncs_pathway2] + + +def get_reference_irrecoverable_carbon_path() -> str: + """Gets the path to the test irrecoverable carbon reference dataset.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "data", + "carbon", + "layers", + "irrecoverable_carbon.tif", + ) + + def get_activity() -> Activity: """Creates a test activity object.""" return Activity( @@ -326,6 +384,7 @@ def get_metric_configuration() -> MetricConfiguration: LAYER_TYPE_ATTRIBUTE: 0, USER_DEFINED_ATTRIBUTE: True, CARBON_PATHS_ATTRIBUTE: [], + PATHWAY_TYPE_ATTRIBUTE: 2, } diff --git a/test/test_metrics.py b/test/test_metrics.py index 46fc0c001..d336c01ac 100644 --- a/test/test_metrics.py +++ b/test/test_metrics.py @@ -16,9 +16,11 @@ from cplus_plugin.lib.reports.metrics import ( create_metrics_expression_context, evaluate_activity_metric, + FUNC_MEAN_BASED_IC, register_metric_functions, unregister_metric_functions, ) +from cplus_plugin.models.base import DataSourceType from cplus_plugin.models.helpers import create_metric_configuration from cplus_plugin.models.report import ActivityContextInfo @@ -27,6 +29,8 @@ get_activity, get_activity_npv_collection, get_metric_column, + get_protected_ncs_pathways, + get_reference_irrecoverable_carbon_path, METRIC_COLUMN_NAME, METRIC_CONFIGURATION_DICT, ) @@ -94,3 +98,43 @@ def test_metrics_scope_in_expression_context(self): metrics_scope_index = context.indexOfScope(BASE_PLUGIN_NAME) self.assertNotEqual(metrics_scope_index, -1) + + def test_activity_irrecoverable_carbon_expression_function(self): + """Test the calculation of an activity's irrecoverable carbon + using an expression function. + """ + # We first need to configure and save the activity + activity = get_activity() + for protected_pathway in get_protected_ncs_pathways(): + activity.add_ncs_pathway(protected_pathway) + + settings_manager.save_activity(activity) + + # Save the project extent for analyzing irrecoverable carbon + extent_box = [ + 30.897412864, + 30.902802731, + -24.699751899, + -24.694362032, + ] + settings_manager.set_value(Settings.SCENARIO_EXTENT, extent_box) + + # Save data source type and path to the reference irrecoverable carbon dataset + ic_reference_path = get_reference_irrecoverable_carbon_path() + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, ic_reference_path + ) + + register_metric_functions() + context = create_metrics_expression_context() + activity_context_info = ActivityContextInfo(get_activity(), 3000) + + result = evaluate_activity_metric( + context, activity_context_info, f"{FUNC_MEAN_BASED_IC}()" + ) + + self.assertTrue(result.success) + self.assertEqual(result.value, 1224) From 6bfb58649f0045d13fe7b1cb709e8eae4e1676e9 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 00:05:12 +0300 Subject: [PATCH 15/25] Fix IC calculation. --- src/cplus_plugin/lib/carbon.py | 86 +++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index e68e659ae..4d0798de4 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -295,9 +295,11 @@ def run(self) -> float: will return -1.0. :rtype: float """ + log_prefix = "Irrecoverable Carbon Calculation" + if len(self._activity.pathways) == 0: log( - f"Irrecoverable Carbon Calculation - There are no pathways in " + f"{log_prefix} - There are no pathways in " f"{self._activity.name} activity.", info=False, ) @@ -311,7 +313,7 @@ def run(self) -> float: if len(protect_pathways) == 0: log( - f"Irrecoverable Carbon Calculation - There are no protect pathways in " + f"{log_prefix} - There are no protect pathways in " f"{self._activity.name} activity.", info=False, ) @@ -321,7 +323,7 @@ def run(self) -> float: valid_protect_layers = [layer for layer in protect_layers if layer.isValid()] if len(valid_protect_layers) == 0: log( - f"Irrecoverable Carbon Calculation - There are no valid protect pathway layers in " + f"{log_prefix} - There are no valid protect pathway layers in " f"{self._activity.name} activity.", info=False, ) @@ -330,28 +332,61 @@ def run(self) -> float: if len(valid_protect_layers) != len(protect_layers): # Just warn if some layers were excluded log( - f"Irrecoverable Carbon Calculation - Some protect pathway layers are invalid and will be " + f"{log_prefix} - Some protect pathway layers are invalid and will be " f"exclude from the irrecoverable carbon calculation.", info=False, ) - # Perform a union of the pathways processing_context = QgsProcessingContext() protect_data_sources = [layer.source() for layer in valid_protect_layers] # First merge the protect NCS pathways into one raster + merge_args = { + "INPUT": protect_data_sources, + "PCT": False, + "SEPARATE": False, + "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, + } + merge_result = None + try: + log( + f"{log_prefix} - Merging protected NCS pathways: {', '.join(protect_data_sources)}..." + ) + merge_result = processing.run( + "gdal:merge", + merge_args, + context=processing_context, + ) + except QgsProcessingException as ex: + log( + f"{log_prefix} - Error creating a union of protect NCS pathways.", + info=False, + ) + return -1.0 + + merged_layer_path = merge_result["OUTPUT"] + merged_layer = QgsRasterLayer(merged_layer_path, "merged_pathways") + if not merged_layer.isValid(): + log( + f"{log_prefix} - Merged protect pathways layer is invalid.", + info=False, + ) + return -1.0 + # Perform a binary transformation to get only the valid pixels for analysis boolean_args = { - "INPUT": protect_data_sources, - "REF_LAYER": protect_data_sources[0], + "INPUT": merged_layer_path, + "REF_LAYER": merged_layer_path, "NODATA_AS_FALSE": True, - "NO_DATA": -9999, "DATA_TYPE": 11, # Since we are only dealing wih 0s and 1s, this will help reduce the size of the output file. "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, } boolean_result = None try: + log( + f"{log_prefix} - Performing binary conversion of merged protected NCS pathways..." + ) boolean_result = processing.run( "native:rasterlogicalor", boolean_args, @@ -359,25 +394,29 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - "Irrecoverable Carbon Calculation - Error creating a union of protect NCS pathways.", + f"{log_prefix} - Error creating a binary of merged protect NCS pathways.", info=False, ) return -1.0 - carbon_calc_layer_path = boolean_result["OUTPUT"] - aggregate_layer = QgsRasterLayer(carbon_calc_layer_path, "aggregate_pathways") - if not aggregate_layer.isValid(): + binary_layer_path = boolean_result["OUTPUT"] + binary_layer = QgsRasterLayer(binary_layer_path, "binary_pathways") + if not binary_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Aggregate protect pathways layer is invalid.", + f"{log_prefix} - Merged protect pathways layer is invalid.", info=False, ) return -1.0 # Reproject the aggregated protect raster if required - if aggregate_layer.crs() != QgsCoordinateReferenceSystem("EPSG:4326"): + if binary_layer.crs() != QgsCoordinateReferenceSystem("EPSG:4326"): + log( + f"{log_prefix} - Binary protected pathways layer has a different CRS from " + f"the reference mean irrecoverable carbon dataset." + ) reproject_args = { - "INPUT": carbon_calc_layer_path, - "SOURCE_CRS": valid_protect_layers[0].crs(), + "INPUT": binary_layer_path, + "SOURCE_CRS": binary_layer.crs(), "TARGET_CRS": QgsCoordinateReferenceSystem( "EPSG:4326" ), # Global IC reference raster @@ -389,6 +428,7 @@ def run(self) -> float: } reproject_result = None try: + log(f"{log_prefix} - Re-projecting binary protected NCS pathways...") reproject_result = processing.run( "gdal:warpreproject", reproject_args, @@ -396,21 +436,20 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - "Irrecoverable Carbon Calculation - Error re-projecting the " + f"{log_prefix} - Error re-projecting the " "aggregate protect NCS pathways.", info=False, ) return -1.0 - carbon_calc_layer_path = reproject_result["OUTPUT"] + binary_layer_path = reproject_result["OUTPUT"] reprojected_protect_layer = QgsRasterLayer( - carbon_calc_layer_path, "reprojected_protect_pathway" + binary_layer_path, "reprojected_protect_pathway" ) if not reprojected_protect_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Reprojected " - "protect pathways layer is invalid.", + f"{log_prefix} - Reprojected " "protect pathways layer is invalid.", info=False, ) return -1.0 @@ -420,8 +459,9 @@ def run(self) -> float: ) if total_irrecoverable_carbon == -1.0: log( - "Irrecoverable Carbon Calculation - Error occurred in " - "calculating the total irrecoverable carbon. See logs for details.", + f"{log_prefix} - Error occurred in " + "calculating the total irrecoverable carbon. See preceding logs " + "for details.", info=False, ) From 85554e1f6b1d658420d2c94363c1d93e3a849faf Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 02:28:11 +0300 Subject: [PATCH 16/25] Fix IC unit tests. --- src/cplus_plugin/lib/carbon.py | 70 +++++++++---------- src/cplus_plugin/lib/validation/validators.py | 2 +- test/test_metrics.py | 24 ++++++- 3 files changed, 57 insertions(+), 39 deletions(-) diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 4d0798de4..465fa69cf 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -11,12 +11,9 @@ from qgis.core import ( QgsCoordinateReferenceSystem, - QgsFeedback, QgsProcessing, QgsProcessingContext, QgsProcessingException, - QgsProcessingFeedback, - QgsProcessingMultiStepFeedback, QgsRasterBlock, QgsRasterIterator, QgsRasterLayer, @@ -24,12 +21,9 @@ ) from qgis import processing -from qgis.PyQt import QtCore - -from ..definitions.constants import NPV_PRIORITY_LAYERS_SEGMENT, PRIORITY_LAYERS_SEGMENT from ..conf import settings_manager, Settings from ..models.base import Activity, DataSourceType, NcsPathwayType -from ..utils import clean_filename, FileUtils, log, tr +from ..utils import log # For now, will set this manually but for future implementation, consider @@ -37,6 +31,8 @@ # reference layer. This area is in hectares i.e. 300m by 300m pixel size. MEAN_REFERENCE_LAYER_AREA = 9.0 +LOG_PREFIX = "Irrecoverable Carbon Calculation" + def calculate_irrecoverable_carbon_from_mean( ncs_pathways_layer: QgsRasterLayer, @@ -68,7 +64,7 @@ def calculate_irrecoverable_carbon_from_mean( """ if not ncs_pathways_layer.isValid(): log( - "Irrecoverable Carbon Calculation - Input union of protect NCS pathways is invalid.", + f"{LOG_PREFIX} - Input union of protect NCS pathways is invalid.", info=False, ) return -1.0 @@ -92,7 +88,7 @@ def calculate_irrecoverable_carbon_from_mean( if not reference_source_path: log( - "Data source for reference irrecoverable carbon layer not found.", + f"{LOG_PREFIX} - Data source for reference irrecoverable carbon layer not found.", info=False, ) return -1.0 @@ -100,8 +96,8 @@ def calculate_irrecoverable_carbon_from_mean( norm_source_path = os.path.normpath(reference_source_path) if not os.path.exists(norm_source_path): error_msg = ( - f"Irrecoverable Carbon Calculation - Data source for reference irrecoverable carbon layer " - f"{norm_source_path} does not exist." + f"{LOG_PREFIX} - Irrecoverable Carbon Calculation - Data source for reference " + f"irrecoverable carbon layer {norm_source_path} does not exist." ) log(error_msg, info=False) return -1.0 @@ -113,7 +109,7 @@ def calculate_irrecoverable_carbon_from_mean( # Check CRS and warn if different if reference_irrecoverable_carbon_layer.crs() != ncs_pathways_layer.crs(): log( - "Irrecoverable Carbon Calculation - Final computation might be incorrect as protect NCS " + f"{LOG_PREFIX} - Final computation might be incorrect as protect NCS " "pathways and reference irrecoverable carbon layer have different " "CRSs.", info=False, @@ -122,7 +118,7 @@ def calculate_irrecoverable_carbon_from_mean( scenario_extent = settings_manager.get_value(Settings.SCENARIO_EXTENT) if scenario_extent is None: log( - "Irrecoverable Carbon Calculation - Scenario extent not defined.", + f"{LOG_PREFIX} - Scenario extent not defined.", info=False, ) return -1.0 @@ -135,10 +131,11 @@ def calculate_irrecoverable_carbon_from_mean( ) ncs_pathways_extent = ncs_pathways_layer.extent() + # if they do not intersect then exit. This might also be related to the CRS. if not reference_extent.intersects(ncs_pathways_extent): log( - "Irrecoverable Carbon Calculation - The protect NCS pathways layer does not intersect with " + f"{LOG_PREFIX} - The protect NCS pathways layer does not intersect with " "the reference irrecoverable carbon layer.", info=False, ) @@ -168,7 +165,7 @@ def calculate_irrecoverable_carbon_from_mean( if not block.isValid(): log( - "Irrecoverable Carbon Calculation - Invalid irrecoverable carbon layer raster block.", + f"{LOG_PREFIX} - Invalid irrecoverable carbon layer raster block.", info=False, ) break @@ -216,7 +213,7 @@ def calculate_irrecoverable_carbon_from_mean( ) if not ncs_block.isValid(): log( - "Irrecoverable Carbon Calculation - Invalid aggregated NCS pathway raster block.", + f"{LOG_PREFIX} - Invalid aggregated NCS pathway raster block.", info=False, ) continue @@ -228,7 +225,7 @@ def calculate_irrecoverable_carbon_from_mean( # any other value apart from the invalid value i.e. 0 pixel value. # In future iterations, consider using QGIS 3.40+ which includes # QgsRasterBlock.as_numpy() that provides the ability to work with - # the raw binary data in numpy. + # the raw binary data in numpy for more faster calculations. ncs_ba_set = set( ncs_block_data[i] for i in range(ncs_block_data.size()) ) @@ -248,7 +245,7 @@ def calculate_irrecoverable_carbon_from_mean( ic_pixel_count = len(irrecoverable_carbon_intersecting_pixel_values) if ic_pixel_count == 0: log( - "Irrecoverable Carbon Calculation - No protect NCS pathways were found in the reference layer.", + f"{LOG_PREFIX} - No protect NCS pathways were found in the reference layer.", info=False, ) return 0.0 @@ -295,11 +292,9 @@ def run(self) -> float: will return -1.0. :rtype: float """ - log_prefix = "Irrecoverable Carbon Calculation" - if len(self._activity.pathways) == 0: log( - f"{log_prefix} - There are no pathways in " + f"{LOG_PREFIX} - There are no pathways in " f"{self._activity.name} activity.", info=False, ) @@ -313,7 +308,7 @@ def run(self) -> float: if len(protect_pathways) == 0: log( - f"{log_prefix} - There are no protect pathways in " + f"{LOG_PREFIX} - There are no protect pathways in " f"{self._activity.name} activity.", info=False, ) @@ -323,7 +318,7 @@ def run(self) -> float: valid_protect_layers = [layer for layer in protect_layers if layer.isValid()] if len(valid_protect_layers) == 0: log( - f"{log_prefix} - There are no valid protect pathway layers in " + f"{LOG_PREFIX} - There are no valid protect pathway layers in " f"{self._activity.name} activity.", info=False, ) @@ -332,7 +327,7 @@ def run(self) -> float: if len(valid_protect_layers) != len(protect_layers): # Just warn if some layers were excluded log( - f"{log_prefix} - Some protect pathway layers are invalid and will be " + f"{LOG_PREFIX} - Some protect pathway layers are invalid and will be " f"exclude from the irrecoverable carbon calculation.", info=False, ) @@ -351,7 +346,7 @@ def run(self) -> float: merge_result = None try: log( - f"{log_prefix} - Merging protected NCS pathways: {', '.join(protect_data_sources)}..." + f"{LOG_PREFIX} - Merging protected NCS pathways: {', '.join(protect_data_sources)}..." ) merge_result = processing.run( "gdal:merge", @@ -360,7 +355,7 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - f"{log_prefix} - Error creating a union of protect NCS pathways.", + f"{LOG_PREFIX} - Error creating a union of protect NCS pathways.", info=False, ) return -1.0 @@ -369,7 +364,7 @@ def run(self) -> float: merged_layer = QgsRasterLayer(merged_layer_path, "merged_pathways") if not merged_layer.isValid(): log( - f"{log_prefix} - Merged protect pathways layer is invalid.", + f"{LOG_PREFIX} - Merged protect pathways layer is invalid.", info=False, ) return -1.0 @@ -379,13 +374,14 @@ def run(self) -> float: "INPUT": merged_layer_path, "REF_LAYER": merged_layer_path, "NODATA_AS_FALSE": True, - "DATA_TYPE": 11, # Since we are only dealing wih 0s and 1s, this will help reduce the size of the output file. + "DATA_TYPE": 0, # quint8 - since we are only dealing wih 0s and 1s, + # this will help reduce the size of the output file. "OUTPUT": QgsProcessing.TEMPORARY_OUTPUT, } boolean_result = None try: log( - f"{log_prefix} - Performing binary conversion of merged protected NCS pathways..." + f"{LOG_PREFIX} - Performing binary conversion of merged protected NCS pathways..." ) boolean_result = processing.run( "native:rasterlogicalor", @@ -394,7 +390,7 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - f"{log_prefix} - Error creating a binary of merged protect NCS pathways.", + f"{LOG_PREFIX} - Error creating a binary of merged protect NCS pathways.", info=False, ) return -1.0 @@ -403,7 +399,7 @@ def run(self) -> float: binary_layer = QgsRasterLayer(binary_layer_path, "binary_pathways") if not binary_layer.isValid(): log( - f"{log_prefix} - Merged protect pathways layer is invalid.", + f"{LOG_PREFIX} - Binary protect pathways layer is invalid.", info=False, ) return -1.0 @@ -411,7 +407,7 @@ def run(self) -> float: # Reproject the aggregated protect raster if required if binary_layer.crs() != QgsCoordinateReferenceSystem("EPSG:4326"): log( - f"{log_prefix} - Binary protected pathways layer has a different CRS from " + f"{LOG_PREFIX} - Binary protected pathways layer has a different CRS from " f"the reference mean irrecoverable carbon dataset." ) reproject_args = { @@ -428,7 +424,7 @@ def run(self) -> float: } reproject_result = None try: - log(f"{log_prefix} - Re-projecting binary protected NCS pathways...") + log(f"{LOG_PREFIX} - Re-projecting binary protected NCS pathways...") reproject_result = processing.run( "gdal:warpreproject", reproject_args, @@ -436,8 +432,8 @@ def run(self) -> float: ) except QgsProcessingException as ex: log( - f"{log_prefix} - Error re-projecting the " - "aggregate protect NCS pathways.", + f"{LOG_PREFIX} - Error re-projecting the " + "binary protect NCS pathways.", info=False, ) return -1.0 @@ -449,7 +445,7 @@ def run(self) -> float: ) if not reprojected_protect_layer.isValid(): log( - f"{log_prefix} - Reprojected " "protect pathways layer is invalid.", + f"{LOG_PREFIX} - Reprojected protect pathways layer is invalid.", info=False, ) return -1.0 @@ -459,7 +455,7 @@ def run(self) -> float: ) if total_irrecoverable_carbon == -1.0: log( - f"{log_prefix} - Error occurred in " + f"{LOG_PREFIX} - Error occurred in " "calculating the total irrecoverable carbon. See preceding logs " "for details.", info=False, diff --git a/src/cplus_plugin/lib/validation/validators.py b/src/cplus_plugin/lib/validation/validators.py index 36ef1358a..1c5d19cbc 100644 --- a/src/cplus_plugin/lib/validation/validators.py +++ b/src/cplus_plugin/lib/validation/validators.py @@ -601,7 +601,7 @@ def is_comparative(self) -> bool: class ResolutionValidator(BaseRuleValidator): """Checks if datasets have the same spatial resolution.""" - DECIMAL_PLACES = 3 + DECIMAL_PLACES = 6 def _validate(self) -> bool: """Checks whether input datasets have the same diff --git a/test/test_metrics.py b/test/test_metrics.py index d336c01ac..4bdefcd43 100644 --- a/test/test_metrics.py +++ b/test/test_metrics.py @@ -9,6 +9,8 @@ from qgis.core import QgsExpression +from processing.core.Processing import Processing + from cplus_plugin.conf import settings_manager, Settings from cplus_plugin.definitions.defaults import BASE_PLUGIN_NAME from cplus_plugin.gui.metrics_builder_dialog import ActivityMetricsBuilder @@ -92,6 +94,25 @@ def test_load_metric_configuration(self): class TestMetricExpressions(TestCase): """Testing management of metrics in QGIS expression environment.""" + def setUp(self): + Processing.initialize() + + def test_metric_expression_function_registration(self): + """Testing the registration of expression functions.""" + register_metric_functions() + + self.assertTrue(QgsExpression.isFunctionName(FUNC_ACTIVITY_NPV)) + self.assertTrue(QgsExpression.isFunctionName(FUNC_PWL_IMPACT)) + + def test_unregister_metric_expression_functions(self): + """Test unregister of expression functions.""" + register_metric_functions() + + unregister_metric_functions() + + self.assertFalse(QgsExpression.isFunctionName(FUNC_ACTIVITY_NPV)) + self.assertFalse(QgsExpression.isFunctionName(FUNC_PWL_IMPACT)) + def test_metrics_scope_in_expression_context(self): """Verify the metrics scope exists in a metrics expression context.""" context = create_metrics_expression_context() @@ -106,6 +127,7 @@ def test_activity_irrecoverable_carbon_expression_function(self): # We first need to configure and save the activity activity = get_activity() for protected_pathway in get_protected_ncs_pathways(): + settings_manager.save_ncs_pathway(protected_pathway) activity.add_ncs_pathway(protected_pathway) settings_manager.save_activity(activity) @@ -130,7 +152,7 @@ def test_activity_irrecoverable_carbon_expression_function(self): register_metric_functions() context = create_metrics_expression_context() - activity_context_info = ActivityContextInfo(get_activity(), 3000) + activity_context_info = ActivityContextInfo(activity, 3000) result = evaluate_activity_metric( context, activity_context_info, f"{FUNC_MEAN_BASED_IC}()" From df6ca2ec30181cd68bec71dda09bd6b6123430f2 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 09:23:32 +0300 Subject: [PATCH 17/25] Add IC download completed flag. --- src/cplus_plugin/api/carbon.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/cplus_plugin/api/carbon.py b/src/cplus_plugin/api/carbon.py index 0e2ed0060..ef3f9424f 100644 --- a/src/cplus_plugin/api/carbon.py +++ b/src/cplus_plugin/api/carbon.py @@ -35,6 +35,7 @@ def __init__(self): self._downloader = None self._event_loop = None self._errors = None + self._successfully_completed = False @property def errors(self) -> typing.List[str]: @@ -45,6 +46,16 @@ def errors(self) -> typing.List[str]: """ return [] if self._errors is None else self._errors + @property + def has_completed(self) -> bool: + """Indicates whether the file was successfully downloaded. + + :returns: True if the file was successfully downloaded, else + False. + :rtype: bool + """ + return self._successfully_completed + def cancel(self): """Cancel the download process.""" if self._downloader: @@ -104,6 +115,8 @@ def _on_download_completed(self, url: QtCore.QUrl): self._event_loop.quit() + self._successfully_completed = True + self.completed.emit() def _on_progress_changed(self, received: int, total: int): From 157d493542a2c8a9bb75fb0e448e1b6a24dccaaf Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 10:15:12 +0300 Subject: [PATCH 18/25] Minor updates to metrics unit tests. --- test/test_metrics.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/test/test_metrics.py b/test/test_metrics.py index 4bdefcd43..045600215 100644 --- a/test/test_metrics.py +++ b/test/test_metrics.py @@ -97,22 +97,6 @@ class TestMetricExpressions(TestCase): def setUp(self): Processing.initialize() - def test_metric_expression_function_registration(self): - """Testing the registration of expression functions.""" - register_metric_functions() - - self.assertTrue(QgsExpression.isFunctionName(FUNC_ACTIVITY_NPV)) - self.assertTrue(QgsExpression.isFunctionName(FUNC_PWL_IMPACT)) - - def test_unregister_metric_expression_functions(self): - """Test unregister of expression functions.""" - register_metric_functions() - - unregister_metric_functions() - - self.assertFalse(QgsExpression.isFunctionName(FUNC_ACTIVITY_NPV)) - self.assertFalse(QgsExpression.isFunctionName(FUNC_PWL_IMPACT)) - def test_metrics_scope_in_expression_context(self): """Verify the metrics scope exists in a metrics expression context.""" context = create_metrics_expression_context() From 56ce3700301affe05100c583dd5cdd5c6a6c941d Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 11:29:40 +0300 Subject: [PATCH 19/25] Remove inapplicable defaults. --- src/cplus_plugin/definitions/defaults.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index b029064f7..c506fbcf2 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -245,23 +245,6 @@ DEFAULT_BASE_COMPARISON_REPORT_NAME = "Scenario Comparison Report" MAXIMUM_COMPARISON_REPORTS = 10 -NPV_EXPRESSION_DESCRIPTION = ( - "Calculates the financial NPV of the current " - "activity. This returns the equivalent of the " - "area of the current activity (in hectares) " - "and multiplies it by the NPV rate (US$/ha) " - "for the activity calculated via the NPV PWL " - "Manager.
NOTE: If the NPV is not defined " - "then the function will return -1.0." -) -PWL_IMPACT_EXPRESSION_DESCRIPTION = ( - "Calculates the impact of the " - "current activity by multiplying " - "the area of the activity (in hectares) " - "by a user-defined number of jobs created per " - "hectare. The activity area will be " - "automatically populated during the computation." -) MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION = ( "Calculates the total irrecoverable carbon (tons C) of " "protection NCS pathways in an activity using the mean " From ec42070f19e4f3bed90789fef926848895901162 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 13:47:11 +0300 Subject: [PATCH 20/25] Update settings UI unit tests. --- .../gui/settings/cplus_options.py | 23 ++++++------- src/cplus_plugin/lib/reports/metrics.py | 2 -- test/test_settings.py | 33 +++++++++++++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/cplus_plugin/gui/settings/cplus_options.py b/src/cplus_plugin/gui/settings/cplus_options.py index ad7f6b71e..1a6d335e8 100644 --- a/src/cplus_plugin/gui/settings/cplus_options.py +++ b/src/cplus_plugin/gui/settings/cplus_options.py @@ -698,25 +698,26 @@ def save_settings(self) -> None: ) # Irrecoverable carbon + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, + self.fw_irrecoverable_carbon.filePath(), + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, self.txt_ic_url.text() + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + self.fw_save_online_file.filePath(), + ) + if self.rb_local.isChecked(): settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value ) - settings_manager.set_value( - Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, - self.fw_irrecoverable_carbon.filePath(), - ) elif self.rb_online.isChecked(): settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value ) - settings_manager.set_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, self.txt_ic_url.text() - ) - settings_manager.set_value( - Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, - self.fw_save_online_file.filePath(), - ) settings_manager.set_value( Settings.IRRECOVERABLE_CARBON_ENABLED, diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 8d66b729f..a1cd1d772 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -19,8 +19,6 @@ from ...definitions.defaults import ( BASE_PLUGIN_NAME, MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION, - NPV_EXPRESSION_DESCRIPTION, - PWL_IMPACT_EXPRESSION_DESCRIPTION, ) from ..carbon import IrrecoverableCarbonCalculator from ...models.report import ActivityContextInfo, MetricEvalResult diff --git a/test/test_settings.py b/test/test_settings.py index 0c43be55d..7535cbcbf 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -2,6 +2,8 @@ from utilities_for_testing import get_qgis_app +from cplus_plugin.definitions.defaults import IRRECOVERABLE_CARBON_API_URL +from cplus_plugin.models.base import DataSourceType from cplus_plugin.gui.settings.cplus_options import CplusSettings from cplus_plugin.gui.settings.report_options import ReportSettingsWidget from cplus_plugin.conf import ( @@ -24,12 +26,26 @@ def test_save(self): carbon_coefficient = 0.1 pathway_suitability_index = 1.5 + irrecoverable_carbon_local_path = "reference_irrecoverable_carbon_local" + irrecoverable_carbon_online_save_as_path = ( + "reference_irrecoverable_carbon_online_save" + ) + # Sets the values in the GUI settings_dialog.folder_data.setFilePath(save_base_dir) settings_dialog.carbon_coefficient_box.setValue(carbon_coefficient) settings_dialog.suitability_index_box.setValue(pathway_suitability_index) + settings_dialog.fw_irrecoverable_carbon.setFilePath( + irrecoverable_carbon_local_path + ) + settings_dialog.fw_save_online_file.setFilePath( + irrecoverable_carbon_online_save_as_path + ) + settings_dialog.txt_ic_url.setText(IRRECOVERABLE_CARBON_API_URL) + settings_dialog.sw_irrecoverable_carbon.setCurrentIndex(0) + # Saves the settings set in the GUI settings_dialog.save_settings() @@ -45,6 +61,23 @@ def test_save(self): ) self.assertEqual(pathway_suitability_index, pathway_suitability_index_val) + self.assertEqual( + settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE), + irrecoverable_carbon_local_path, + ) + self.assertEqual( + settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH), + irrecoverable_carbon_online_save_as_path, + ) + self.assertEqual( + settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE), + IRRECOVERABLE_CARBON_API_URL, + ) + self.assertEqual( + settings_manager.get_value(Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE), + DataSourceType.LOCAL.value, + ) + def test_load(self): """A test which will check if the main CPLUS settings are loaded correctly into the settings UI when calling the load_settings function. From 27f35a3b47f62ec0029311e9d0eb77e1949725a1 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 16 Dec 2024 14:06:23 +0300 Subject: [PATCH 21/25] Further updates to settings UI unit tests. --- test/test_settings.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/test_settings.py b/test/test_settings.py index 7535cbcbf..2cebc72be 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -88,6 +88,11 @@ def test_load(self): save_carbon_coefficient = 0.1 save_pathway_suitability_index = 1.5 + irrecoverable_carbon_local_path = "reference_irrecoverable_carbon_local" + irrecoverable_carbon_online_save_as_path = ( + "reference_irrecoverable_carbon_online_save" + ) + # Set all values for testing settings_manager.set_value(Settings.BASE_DIR, save_base_dir) @@ -96,6 +101,11 @@ def test_load(self): Settings.PATHWAY_SUITABILITY_INDEX, save_pathway_suitability_index ) + settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, irrecoverable_carbon_local_path), + settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, irrecoverable_carbon_online_save_as_path) + settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL) + settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value) + # Loads the values into the GUI settings_dialog.load_settings() @@ -109,6 +119,11 @@ def test_load(self): pathway_suitability_index = settings_dialog.suitability_index_box.value() self.assertEqual(save_pathway_suitability_index, pathway_suitability_index) + self.assertEqual(settings_dialog.fw_irrecoverable_carbon.filePath(), irrecoverable_carbon_local_path) + self.assertEqual(settings_dialog.txt_ic_url.text(), IRRECOVERABLE_CARBON_API_URL) + self.assertEqual(settings_dialog.fw_save_online_file.filePath(), irrecoverable_carbon_online_save_as_path) + self.assertTrue(settings_dialog.rb_local.isChecked()) + def test_base_dir_exist(self): """A test which checks if the base_dir_exists function works as it should. A test is done for when the base directory exist, From 3c717170a8636e46f3b22f15dc83068eae747ac4 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 17 Dec 2024 01:37:34 +0300 Subject: [PATCH 22/25] Incorporate unit tests for the irrecoverable carbon downloader. --- src/cplus_plugin/api/carbon.py | 27 +++-- test/test_api_request.py | 174 ++++++++++++++++++++++++++++++++- test/test_settings.py | 31 ++++-- test/test_validation.py | 1 - 4 files changed, 214 insertions(+), 19 deletions(-) diff --git a/src/cplus_plugin/api/carbon.py b/src/cplus_plugin/api/carbon.py index ef3f9424f..08957cf7c 100644 --- a/src/cplus_plugin/api/carbon.py +++ b/src/cplus_plugin/api/carbon.py @@ -29,13 +29,14 @@ class IrrecoverableCarbonDownloadTask(QgsTask): canceled = QtCore.pyqtSignal() completed = QtCore.pyqtSignal() started = QtCore.pyqtSignal() + exited = QtCore.pyqtSignal() def __init__(self): super().__init__(tr("Downloading irrecoverable carbon dataset")) self._downloader = None self._event_loop = None self._errors = None - self._successfully_completed = False + self._exited = False @property def errors(self) -> typing.List[str]: @@ -47,14 +48,13 @@ def errors(self) -> typing.List[str]: return [] if self._errors is None else self._errors @property - def has_completed(self) -> bool: - """Indicates whether the file was successfully downloaded. + def has_exited(self) -> bool: + """Indicates whether the downloader has exited. - :returns: True if the file was successfully downloaded, else - False. + :returns: True if the downloader exited, else False. :rtype: bool """ - return self._successfully_completed + return self._exited def cancel(self): """Cancel the download process.""" @@ -63,10 +63,11 @@ def cancel(self): self._update_download_status(ApiRequestStatus.CANCELED, "Download canceled") self.disconnect_receivers() - self._event_loop.quit() - super().cancel() + if self._event_loop: + self._event_loop.quit() + log("Irrecoverable carbon dataset task canceled.") def _on_error_occurred(self, error_messages: typing.List[str]): @@ -96,6 +97,14 @@ def _on_download_canceled(self): self.canceled.emit() + def _on_download_exited(self): + """Slot raised when the download has exited.""" + self._event_loop.quit() + + self._exited = True + + self.exited.emit() + def _on_download_completed(self, url: QtCore.QUrl): """Slot raised when the download is complete. @@ -158,6 +167,7 @@ def disconnect_receivers(self): self._downloader.downloadCanceled.disconnect(self._on_download_canceled) self._downloader.downloadProgress.disconnect(self._on_progress_changed) self._downloader.downloadCompleted.disconnect(self._on_download_completed) + self._downloader.downloadExited.disconnect(self._on_download_exited) def run(self) -> bool: """Initiates the download of irrecoverable carbon dataset process and @@ -232,6 +242,7 @@ def run(self) -> bool: self._downloader.downloadCanceled.connect(self._on_download_canceled) self._downloader.downloadProgress.connect(self._on_progress_changed) self._downloader.downloadCompleted.connect(self._on_download_completed) + self._downloader.downloadExited.connect(self._on_download_exited) self._update_download_status( ApiRequestStatus.NOT_STARTED, tr("Download not started") diff --git a/test/test_api_request.py b/test/test_api_request.py index 50997fd78..dd5d73b88 100644 --- a/test/test_api_request.py +++ b/test/test_api_request.py @@ -1,8 +1,14 @@ +import os +import tempfile +import typing import unittest import datetime from unittest.mock import patch, MagicMock + from PyQt5 import QtCore -from PyQt5.QtCore import QIODevice, QByteArray +from PyQt5.QtCore import QCoreApplication, QIODevice, QByteArray + +from qgis.core import QgsRasterLayer, QgsTask from qgis.PyQt.QtNetwork import QNetworkReply from cplus_plugin.api.request import ( CplusApiRequestError, @@ -11,8 +17,14 @@ CplusApiUrl, CplusApiRequest, ) -from cplus_plugin.conf import Settings -from cplus_plugin.definitions.defaults import BASE_API_URL +from cplus_plugin.api.carbon import IrrecoverableCarbonDownloadTask +from cplus_plugin.conf import settings_manager, Settings +from cplus_plugin.definitions.defaults import BASE_API_URL, IRRECOVERABLE_CARBON_API_URL +from cplus_plugin.models.base import DataSourceType + +from utilities_for_testing import get_qgis_app + +QGIS_APP, CANVAS, IFACE, PARENT = get_qgis_app() def mocked_exec_loop(): @@ -370,3 +382,159 @@ def test_fetch_scenario_detail(self, mock_get): mock_get.assert_called_once_with( self.api_request.urls.scenario_detail("test-scenario-uuid") ) + + +class TestIrrecoverableCarbonDownloader(unittest.TestCase): + """Tests for the IrrecoverableCarbonDownloadTask.""" + + def setUp(self): + self.task_manager = QGIS_APP.taskManager() + + def _get_test_extent(self) -> typing.List[float]: + """Returns the extent for setting the downloaded dataset's AOI.""" + return [ + 30.897412864, + 30.902802731, + -24.699751899, + -24.694362032, + ] + + def test_successful_download(self): + """Test the successful download of the reference irrecoverable + carbon dataset. + """ + settings_manager.set_value(Settings.SCENARIO_EXTENT, self._get_test_extent()) + + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value + ) + save_path = tempfile.NamedTemporaryFile(suffix=".tif").name + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, save_path + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL + ) + + download_complete = False + download_start = False + + def on_download_start(): + nonlocal download_start + download_start = True + + def on_download_complete(): + nonlocal download_complete + download_complete = True + + downloader = IrrecoverableCarbonDownloadTask() + downloader.started.connect(on_download_start) + downloader.completed.connect(on_download_complete) + + self.task_manager.addTask(downloader) + + while not downloader.has_exited: + QCoreApplication.processEvents() + + self.assertTrue(download_start) + self.assertTrue(download_complete) + self.assertTrue(os.path.isfile(save_path)) + self.assertTrue(QgsRasterLayer(save_path, "carbon_layer").isValid()) + + def test_invalid_url(self): + """Test if the downloader flags that an error has occurred when the + URL is invalid. + """ + settings_manager.set_value(Settings.SCENARIO_EXTENT, self._get_test_extent()) + + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value + ) + save_path = tempfile.NamedTemporaryFile(suffix=".tif").name + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, save_path + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, "https://justtestcplus.com" + ) + + download_error = False + + def on_download_error(): + nonlocal download_error + download_error = True + + downloader = IrrecoverableCarbonDownloadTask() + downloader.error_occurred.connect(on_download_error) + + self.task_manager.addTask(downloader) + + while not downloader.has_exited: + QCoreApplication.processEvents() + + self.assertTrue(download_error) + + def test_invalid_save_as_path(self): + """Test if the downloader flags that an error has occurred when the + path for saving the downloaded path is invalid. + """ + settings_manager.set_value(Settings.SCENARIO_EXTENT, self._get_test_extent()) + + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value + ) + save_path = "/mnt/cplus-plugin/test" + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, save_path + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL + ) + + download_error = False + + def on_download_error(): + nonlocal download_error + download_error = True + + downloader = IrrecoverableCarbonDownloadTask() + downloader.error_occurred.connect(on_download_error) + + self.task_manager.addTask(downloader) + + while not downloader.has_exited: + QCoreApplication.processEvents() + + self.assertTrue(download_error) + + def test_download_cancel(self): + """Test canceling of the download process.""" + settings_manager.set_value(Settings.SCENARIO_EXTENT, self._get_test_extent()) + + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.ONLINE.value + ) + save_path = tempfile.NamedTemporaryFile(suffix=".tif").name + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, save_path + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL + ) + + download_canceled = False + + def on_download_canceled(): + nonlocal download_canceled + download_canceled = True + + downloader = IrrecoverableCarbonDownloadTask() + downloader.canceled.connect(on_download_canceled) + downloader.started.connect(downloader.cancel) + + self.task_manager.addTask(downloader) + + while not downloader.has_exited: + QCoreApplication.processEvents() + + self.assertTrue(download_canceled) diff --git a/test/test_settings.py b/test/test_settings.py index 2cebc72be..540774231 100644 --- a/test/test_settings.py +++ b/test/test_settings.py @@ -101,10 +101,19 @@ def test_load(self): Settings.PATHWAY_SUITABILITY_INDEX, save_pathway_suitability_index ) - settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, irrecoverable_carbon_local_path), - settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, irrecoverable_carbon_online_save_as_path) - settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL) - settings_manager.set_value(Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_LOCAL_SOURCE, irrecoverable_carbon_local_path + ), + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_LOCAL_PATH, + irrecoverable_carbon_online_save_as_path, + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, IRRECOVERABLE_CARBON_API_URL + ) + settings_manager.set_value( + Settings.IRRECOVERABLE_CARBON_SOURCE_TYPE, DataSourceType.LOCAL.value + ) # Loads the values into the GUI settings_dialog.load_settings() @@ -119,9 +128,17 @@ def test_load(self): pathway_suitability_index = settings_dialog.suitability_index_box.value() self.assertEqual(save_pathway_suitability_index, pathway_suitability_index) - self.assertEqual(settings_dialog.fw_irrecoverable_carbon.filePath(), irrecoverable_carbon_local_path) - self.assertEqual(settings_dialog.txt_ic_url.text(), IRRECOVERABLE_CARBON_API_URL) - self.assertEqual(settings_dialog.fw_save_online_file.filePath(), irrecoverable_carbon_online_save_as_path) + self.assertEqual( + settings_dialog.fw_irrecoverable_carbon.filePath(), + irrecoverable_carbon_local_path, + ) + self.assertEqual( + settings_dialog.txt_ic_url.text(), IRRECOVERABLE_CARBON_API_URL + ) + self.assertEqual( + settings_dialog.fw_save_online_file.filePath(), + irrecoverable_carbon_online_save_as_path, + ) self.assertTrue(settings_dialog.rb_local.isChecked()) def test_base_dir_exist(self): diff --git a/test/test_validation.py b/test/test_validation.py index c474aec19..26a5897db 100644 --- a/test/test_validation.py +++ b/test/test_validation.py @@ -6,7 +6,6 @@ from unittest import TestCase from qgis.PyQt.QtCore import QCoreApplication -from qgis.PyQt.QtTest import QSignalSpy from cplus_plugin.lib.validation.configs import ( carbon_resolution_validation_config, From 613788e7b0a7abd543aaeb8ebf19ea16e33058a3 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 17 Dec 2024 01:57:42 +0300 Subject: [PATCH 23/25] Update developer docs. --- docs/developer/api/core/api_carbon.md | 21 +++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 22 insertions(+) create mode 100644 docs/developer/api/core/api_carbon.md diff --git a/docs/developer/api/core/api_carbon.md b/docs/developer/api/core/api_carbon.md new file mode 100644 index 000000000..65c9b7570 --- /dev/null +++ b/docs/developer/api/core/api_carbon.md @@ -0,0 +1,21 @@ +--- +title: Conservation International +summary: + - Jeremy Prior + - Ketan Bamniya +date: +some_url: +copyright: +contact: +license: This program is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. +--- + +# Carbon Calculation Utilities + +::: src.cplus_plugin.lib.carbon + handler: python + options: + docstring_style: sphinx + heading_level: 1 + show_source: true + show_root_heading: false \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 020ead04e..5894d4b42 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,6 +48,7 @@ nav: - Settings: developer/api/core/api_settings.md - Utilities: developer/api/core/api_utils.md - Financials: developer/api/core/api_financials.md + - Carbon: developer/api/core/api_carbon.md - Reports: - Comparison Table: developer/api/core/api_report_scenario_comparison_table.md - Generator: developer/api/core/api_reports_generator.md From f460b2104598c5f1cfaa1b817b46962cb455abad Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 17 Dec 2024 01:59:37 +0300 Subject: [PATCH 24/25] Terminology change. --- src/cplus_plugin/gui/pixel_value_editor_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cplus_plugin/gui/pixel_value_editor_dialog.py b/src/cplus_plugin/gui/pixel_value_editor_dialog.py index 8811e7912..1c56a5169 100644 --- a/src/cplus_plugin/gui/pixel_value_editor_dialog.py +++ b/src/cplus_plugin/gui/pixel_value_editor_dialog.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Dialog for setting the pixel value for styling IMs. +Dialog for setting the pixel value for styling activities. """ from collections import OrderedDict From 979f0e0a6411a25aa5316dd3a52078b286cd8149 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 18 Dec 2024 01:03:54 +0300 Subject: [PATCH 25/25] Add more log information and terminology updates. --- src/cplus_plugin/definitions/defaults.py | 4 ++-- src/cplus_plugin/lib/carbon.py | 12 +++++++++--- src/cplus_plugin/lib/reports/metrics.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index ce49defad..1e27b003f 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -266,12 +266,12 @@ MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION = ( "Calculates the total irrecoverable carbon (tons C) of " - "protection NCS pathways in an activity using the mean " + "protect NCS pathways in an activity using the mean " "reference irrecoverable carbon dataset. This dataset " "needs to be defined in the CPLUS settings for this " "expression to be evaluated.
NOTE: A value of -1.0 " "will be returned if an error is encountered, or 0.0 if " - "there are no protected NCS pathways in the activity or " + "there are no protect NCS pathways in the activity or " "no overlapping pixels with the reference layer in the " "area of interest." ) diff --git a/src/cplus_plugin/lib/carbon.py b/src/cplus_plugin/lib/carbon.py index 465fa69cf..4f34c1485 100644 --- a/src/cplus_plugin/lib/carbon.py +++ b/src/cplus_plugin/lib/carbon.py @@ -254,6 +254,8 @@ def calculate_irrecoverable_carbon_from_mean( ic_pixel_count ) + log("Calculating the total irrecoverable carbon...") + return MEAN_REFERENCE_LAYER_AREA * ic_pixel_count * ic_mean @@ -346,7 +348,7 @@ def run(self) -> float: merge_result = None try: log( - f"{LOG_PREFIX} - Merging protected NCS pathways: {', '.join(protect_data_sources)}..." + f"{LOG_PREFIX} - Merging protect NCS pathways: {', '.join(protect_data_sources)}..." ) merge_result = processing.run( "gdal:merge", @@ -381,7 +383,7 @@ def run(self) -> float: boolean_result = None try: log( - f"{LOG_PREFIX} - Performing binary conversion of merged protected NCS pathways..." + f"{LOG_PREFIX} - Performing binary conversion of merged protect NCS pathways..." ) boolean_result = processing.run( "native:rasterlogicalor", @@ -407,7 +409,7 @@ def run(self) -> float: # Reproject the aggregated protect raster if required if binary_layer.crs() != QgsCoordinateReferenceSystem("EPSG:4326"): log( - f"{LOG_PREFIX} - Binary protected pathways layer has a different CRS from " + f"{LOG_PREFIX} - Binary protect pathways layer has a different CRS from " f"the reference mean irrecoverable carbon dataset." ) reproject_args = { @@ -461,4 +463,8 @@ def run(self) -> float: info=False, ) + log( + f"Finished calculating the total irrecoverable carbon of {self._activity.name} as {total_irrecoverable_carbon!s}" + ) + return total_irrecoverable_carbon diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 88af1bd3f..fb5ac5c38 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -20,7 +20,7 @@ BASE_PLUGIN_NAME, MEAN_BASED_IRRECOVERABLE_CARBON_EXPRESSION_DESCRIPTION, NPV_EXPRESSION_DESCRIPTION, - PWL_IMPACT_EXPRESSION_DESCRIPTION + PWL_IMPACT_EXPRESSION_DESCRIPTION, ) from ..carbon import IrrecoverableCarbonCalculator from ..financials import calculate_activity_npv