diff --git a/src/cplus_plugin/definitions/constants.py b/src/cplus_plugin/definitions/constants.py
index 6fc67230a..7f29ac97c 100644
--- a/src/cplus_plugin/definitions/constants.py
+++ b/src/cplus_plugin/definitions/constants.py
@@ -43,6 +43,7 @@
COMPUTED_ATTRIBUTE = "use_computed"
NPV_MAPPINGS_ATTRIBUTE = "mappings"
REMOVE_EXISTING_ATTRIBUTE = "remove_existing"
+MANUAL_NPV_ATTRIBUTE = "manual_npv"
ACTIVITY_IDENTIFIER_PROPERTY = "activity_identifier"
NPV_COLLECTION_PROPERTY = "npv_collection"
diff --git a/src/cplus_plugin/gui/financials/npv_manager_dialog.py b/src/cplus_plugin/gui/financials/npv_manager_dialog.py
index e2351fd69..24f3a7b58 100644
--- a/src/cplus_plugin/gui/financials/npv_manager_dialog.py
+++ b/src/cplus_plugin/gui/financials/npv_manager_dialog.py
@@ -27,12 +27,44 @@
from ...lib.financials import compute_discount_value
from ...utils import FileUtils, open_documentation, tr
+
WidgetUi, _ = loadUiType(
os.path.join(os.path.dirname(__file__), "../../ui/financial_pwl_dialog.ui")
)
-class FinancialValueItemDelegate(QtWidgets.QStyledItemDelegate):
+DEFAULT_DECIMAL_PLACES = 2
+
+
+class DisplayValueFormatterItemDelegate(QtWidgets.QStyledItemDelegate):
+ """
+ Delegate for formatting numeric values using thousand comma separator,
+ number of decimal places etc.
+ """
+
+ def displayText(self, value: float, locale: QtCore.QLocale) -> str:
+ """Format the value to incorporate thousand comma separator.
+
+ :param value: Value of the display role provided by the model.
+ :type value: float
+
+ :param locale: Locale for the value in the display role.
+ :type locale: QtCore.QLocale
+
+ :returns: Formatted value of the display role data.
+ :rtype: str
+ """
+ if value is None:
+ return ""
+
+ formatter = QgsBasicNumericFormat()
+ formatter.setShowThousandsSeparator(True)
+ formatter.setNumberDecimalPlaces(DEFAULT_DECIMAL_PLACES)
+
+ return formatter.formatDouble(float(value), QgsNumericFormatContext())
+
+
+class FinancialValueItemDelegate(DisplayValueFormatterItemDelegate):
"""
Delegate for ensuring only numbers are specified in financial value
fields.
@@ -82,27 +114,6 @@ def setEditorData(self, widget: QtWidgets.QWidget, idx: QtCore.QModelIndex):
else:
widget.setText(str(value))
- def displayText(self, value: float, locale: QtCore.QLocale) -> str:
- """Format the value to incorporate thousand comma separator.
-
- :param value: Value of the display role provided by the model.
- :type value: float
-
- :param locale: Locale for the value in the display role.
- :type locale: QtCore.QLocale
-
- :returns: Formatted value of the display role data.
- :rtype: str
- """
- if value is None:
- return ""
-
- formatter = QgsBasicNumericFormat()
- formatter.setShowThousandsSeparator(True)
- formatter.setNumberDecimalPlaces(2)
-
- return formatter.formatDouble(float(value), QgsNumericFormatContext())
-
def setModelData(
self,
widget: QtWidgets.QWidget,
@@ -150,40 +161,14 @@ def updateEditorGeometry(
widget.setGeometry(option.rect)
-class ValueFormatterItemDelegate(QtWidgets.QStyledItemDelegate):
- """
- Delegate for formatting numeric values using thousand comma separator,
- number of decimal places etc.
- """
-
- def displayText(self, value: float, locale: QtCore.QLocale) -> str:
- """Format the value to incorporate thousand comma separator.
-
- :param value: Value of the display role provided by the model.
- :type value: float
-
- :param locale: Locale for the value in the display role.
- :type locale: QtCore.QLocale
-
- :returns: Formatted value of the display role data.
- :rtype: str
- """
- if value is None:
- return ""
-
- formatter = QgsBasicNumericFormat()
- formatter.setShowThousandsSeparator(True)
- formatter.setNumberDecimalPlaces(2)
-
- return formatter.formatDouble(float(value), QgsNumericFormatContext())
-
-
class NpvPwlManagerDialog(QtWidgets.QDialog, WidgetUi):
"""Dialog for managing NPV priority weighting layers for activities."""
DEFAULT_YEARS = 5
DEFAULT_DISCOUNT_RATE = 0.0
- NUM_DECIMAL_PLACES = 2
+ NUM_DECIMAL_PLACES = DEFAULT_DECIMAL_PLACES
+ MINIMUM_NPV_VALUE = 0.0
+ MAXIMUM_NPV_VALUE = 1000000000.000000
def __init__(self, parent=None):
super().__init__(parent)
@@ -194,6 +179,11 @@ def __init__(self, parent=None):
self._message_bar = QgsMessageBar()
self.vl_notification.addWidget(self._message_bar)
+ self.sb_npv.setMaximum(self.MAXIMUM_NPV_VALUE)
+ self.sb_npv.setMinimum(self.MINIMUM_NPV_VALUE)
+ self.sb_npv.setDecimals(self.NUM_DECIMAL_PLACES)
+ self.sb_npv.setReadOnly(True)
+
# Initialize UI
help_icon = FileUtils.get_icon("mActionHelpContents_green.svg")
self.btn_help.setIcon(help_icon)
@@ -204,7 +194,17 @@ def __init__(self, parent=None):
ok_button = self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok)
ok_button.setText(tr("Update"))
- self.buttonBox.accepted.connect(self._on_accepted)
+ # Prevent button from being triggered when Enter is pressed
+ # and use dummy interceptor below
+ ok_button.setAutoDefault(False)
+ ok_button.setDefault(False)
+ ok_button.clicked.connect(self._on_accepted)
+
+ # Workaround for intercepting Enter triggers thus preventing the
+ # dialog from being accepted. Not ideal but Qt issue
+ self._enter_interceptor_btn.setAutoDefault(True)
+ self._enter_interceptor_btn.setDefault(True)
+ self._enter_interceptor_btn.setVisible(False)
self._npv = None
@@ -230,7 +230,7 @@ def __init__(self, parent=None):
self.tv_revenue_costs.setModel(self._npv_model)
self._revenue_delegate = FinancialValueItemDelegate()
self._costs_delegate = FinancialValueItemDelegate()
- self._discounted_value_delegate = ValueFormatterItemDelegate()
+ self._discounted_value_delegate = DisplayValueFormatterItemDelegate()
self.tv_revenue_costs.setItemDelegateForColumn(1, self._revenue_delegate)
self.tv_revenue_costs.setItemDelegateForColumn(2, self._costs_delegate)
self.tv_revenue_costs.setItemDelegateForColumn(
@@ -242,6 +242,7 @@ def __init__(self, parent=None):
self.sb_num_years.valueChanged.connect(self.on_number_years_changed)
self.sb_discount.valueChanged.connect(self.on_discount_rate_changed)
+ self.sb_npv.valueChanged.connect(self.on_total_npv_value_changed)
# Set default values
self.reset_npv_values()
@@ -266,6 +267,7 @@ def __init__(self, parent=None):
)
self.gp_npv_pwl.toggled.connect(self._on_activity_npv_groupbox_toggled)
+ self.cb_manual_npv.toggled.connect(self._on_manual_npv_toggled)
def open_help(self, activated: bool):
"""Opens the user documentation for the plugin in a browser."""
@@ -333,6 +335,25 @@ def on_npv_computation_item_changed(self, item: QtGui.QStandardItem):
self.update_discounted_value(item.row())
self._update_current_activity_npv()
+ def on_total_npv_value_changed(self, value: float):
+ """Slot raised when the total NPV has changed either through
+ automatic computation or manual input.
+
+ :param value: NPV value.
+ :type value: float
+ """
+ if (
+ not self._current_activity_identifier is None
+ and self.cb_manual_npv.isChecked()
+ ):
+ activity_npv = self._get_current_activity_npv()
+ if activity_npv is not None:
+ activity_npv.params.absolute_npv = self.sb_npv.value()
+
+ # Update NPV normalization range
+ if self.cb_computed_npv.isChecked():
+ self._compute_min_max_range()
+
def update_discounted_value(self, row: int):
"""Updated the discounted value for the given row number.
@@ -366,7 +387,8 @@ def update_discounted_value(self, row: int):
discounted_value_index, rounded_discounted_value, QtCore.Qt.EditRole
)
- self.compute_npv()
+ if not self.cb_manual_npv.isChecked():
+ self.compute_npv()
def update_all_discounted_values(self):
"""Updates all discounted values that had already been
@@ -406,12 +428,7 @@ def compute_npv(self):
self._npv = npv
- # Format display
- formatter = QgsBasicNumericFormat()
- formatter.setShowThousandsSeparator(True)
- formatter.setNumberDecimalPlaces(2)
-
- self.txt_npv.setText(formatter.formatDouble(npv, QgsNumericFormatContext()))
+ self.sb_npv.setValue(npv)
def on_years_removed(self, index: QtCore.QModelIndex, start: int, end: int):
"""Slot raised when the year rows have been removed.
@@ -430,7 +447,7 @@ def on_years_removed(self, index: QtCore.QModelIndex, start: int, end: int):
def copy_npv(self):
"""Copy NPV to the clipboard."""
- QgsApplication.instance().clipboard().setText(self.txt_npv.text())
+ QgsApplication.instance().clipboard().setText(str(self.sb_npv.value()))
def is_valid(self) -> bool:
"""Verifies if the input data is valid.
@@ -457,26 +474,33 @@ def is_valid(self) -> bool:
activity_name = activity_mapping.activity.name
- # First check if size of yearly rates matches the numbers of years
- if activity_mapping.params.years != len(
- activity_mapping.params.yearly_rates
- ):
- msg = "Size of yearly rates and number of years do not match."
- self._show_warning_message(msg)
- if status:
- status = False
- continue
-
- missing_value_rows = []
- for i, rates_info in enumerate(activity_mapping.params.yearly_rates):
- if len(rates_info) < 3 or None in rates_info:
- missing_value_rows.append(str(i + 1))
+ if activity_mapping.params.manual_npv:
+ if activity_mapping.params.absolute_npv is None:
+ missing_value_tr = tr("Manual NPV is missing")
+ msg = f"{activity_name}: {missing_value_tr}."
+ self._show_warning_message(msg)
- if len(missing_value_rows) > 0:
- msg = f"{activity_name}: {missing_msg_tr} {', '.join(missing_value_rows)}."
- self._show_warning_message(msg)
- if status:
- status = False
+ else:
+ # First check if size of yearly rates matches the numbers of years
+ if activity_mapping.params.years != len(
+ activity_mapping.params.yearly_rates
+ ):
+ msg = tr("Size of yearly rates and number of years do not match.")
+ self._show_warning_message(f"{activity_name}: {msg}")
+ if status:
+ status = False
+ continue
+
+ missing_value_rows = []
+ for i, rates_info in enumerate(activity_mapping.params.yearly_rates):
+ if len(rates_info) < 3 or None in rates_info:
+ missing_value_rows.append(str(i + 1))
+
+ if len(missing_value_rows) > 0:
+ msg = f"{activity_name}: {missing_msg_tr} {', '.join(missing_value_rows)}."
+ self._show_warning_message(msg)
+ if status:
+ status = False
return status
@@ -558,7 +582,8 @@ def reset_npv_values(self):
self._npv_model.set_number_of_years(self.DEFAULT_YEARS)
self.sb_num_years.setValue(self.DEFAULT_YEARS)
self.sb_discount.setValue(self.DEFAULT_DISCOUNT_RATE)
- self.txt_npv.setText("")
+ self.cb_manual_npv.setChecked(False)
+ self.sb_npv.setValue(0.0)
self.gp_npv_pwl.setChecked(False)
def on_activity_selection_changed(
@@ -589,6 +614,18 @@ def on_activity_selection_changed(
self.load_activity_npv(activity_npv)
+ def _get_current_activity_npv(self) -> typing.Optional[ActivityNpv]:
+ """Gets the current activity NPV model.
+
+ :returns: The current activity NPV model or None if the current
+ identifier is not set or if no model was found in the collection.
+ :rtype: ActivityNpv
+ """
+ if self._current_activity_identifier is None:
+ return None
+
+ return self._npv_collection.activity_npv(self._current_activity_identifier)
+
def load_activity_npv(self, activity_npv: ActivityNpv):
"""Loads NPV parameters for an activity.
@@ -598,6 +635,8 @@ def load_activity_npv(self, activity_npv: ActivityNpv):
self._current_activity_identifier = activity_npv.activity_id
npv_params = activity_npv.params
+ saved_total_npv = npv_params.absolute_npv
+
self.gp_npv_pwl.setChecked(activity_npv.enabled)
self.sb_num_years.blockSignals(True)
@@ -608,8 +647,13 @@ def load_activity_npv(self, activity_npv: ActivityNpv):
self.sb_discount.setValue(npv_params.discount)
self.sb_discount.blockSignals(False)
+ self.cb_manual_npv.setChecked(npv_params.manual_npv)
+ if npv_params.manual_npv:
+ self._on_manual_npv_toggled(True)
+
self._npv_model.set_number_of_years(npv_params.years)
+ # Update total NPV if auto-computed or manually defined
for i, year_info in enumerate(npv_params.yearly_rates):
if len(year_info) < 3:
continue
@@ -621,6 +665,9 @@ def load_activity_npv(self, activity_npv: ActivityNpv):
self.update_all_discounted_values()
+ if npv_params.manual_npv:
+ self.sb_npv.setValue(saved_total_npv)
+
def _update_current_activity_npv(self):
"""Update NPV parameters changes made in the UI to the underlying
activity NPV.
@@ -632,27 +679,32 @@ def _update_current_activity_npv(self):
self._current_activity_identifier
)
- activity_npv.params.years = self.sb_num_years.value()
- activity_npv.params.discount = self.sb_discount.value()
+ activity_npv.params.manual_npv = self.cb_manual_npv.isChecked()
activity_npv.enabled = self.gp_npv_pwl.isChecked()
- yearly_rates = []
- for row in range(self._npv_model.rowCount()):
- revenue_value = self._npv_model.data(
- self._npv_model.index(row, 1), QtCore.Qt.EditRole
- )
- cost_value = self._npv_model.data(
- self._npv_model.index(row, 2), QtCore.Qt.EditRole
- )
- discount_value = self._npv_model.data(
- self._npv_model.index(row, 3), QtCore.Qt.EditRole
- )
- yearly_rates.append((revenue_value, cost_value, discount_value))
+ if not activity_npv.params.manual_npv:
+ activity_npv.params.years = self.sb_num_years.value()
+ activity_npv.params.discount = self.sb_discount.value()
+
+ yearly_rates = []
+ for row in range(self._npv_model.rowCount()):
+ revenue_value = self._npv_model.data(
+ self._npv_model.index(row, 1), QtCore.Qt.EditRole
+ )
+ cost_value = self._npv_model.data(
+ self._npv_model.index(row, 2), QtCore.Qt.EditRole
+ )
+ discount_value = self._npv_model.data(
+ self._npv_model.index(row, 3), QtCore.Qt.EditRole
+ )
+ yearly_rates.append((revenue_value, cost_value, discount_value))
- activity_npv.params.yearly_rates = yearly_rates
+ activity_npv.params.yearly_rates = yearly_rates
- if self._npv is not None:
+ if not self.cb_manual_npv.isChecked() and self._npv is not None:
activity_npv.params.absolute_npv = self._npv
+ else:
+ activity_npv.params.absolute_npv = self.sb_npv.value()
# Update NPV normalization range
if self.cb_computed_npv.isChecked():
@@ -696,11 +748,40 @@ def _on_activity_npv_groupbox_toggled(self, checked: bool):
if not checked:
self._update_current_activity_npv()
else:
- activity_npv = self._npv_collection.activity_npv(
- self._current_activity_identifier
- )
- activity_npv.enabled = self.gp_npv_pwl.isChecked()
+ activity_npv = self._get_current_activity_npv()
+ if activity_npv is not None:
+ activity_npv.enabled = self.gp_npv_pwl.isChecked()
# Update NPV normalization range
if self.cb_computed_npv.isChecked():
self._compute_min_max_range()
+
+ def _on_manual_npv_toggled(self, checked: bool):
+ """Slot raised to enable/disable manual NPV value.
+
+ :param checked: True if the manual NPV is enabled else False.
+ :type checked: bool
+ """
+ if checked:
+ self.sb_npv.setReadOnly(False)
+ self.sb_npv.setFocus()
+ self.enable_npv_parameters_widgets(False)
+
+ activity_npv = self._get_current_activity_npv()
+ if activity_npv is not None:
+ activity_npv.params.manual_npv = self.cb_manual_npv.isChecked()
+
+ else:
+ self.sb_npv.setReadOnly(True)
+ self.enable_npv_parameters_widgets(True)
+ self.compute_npv()
+
+ def enable_npv_parameters_widgets(self, enable: bool):
+ """Enable or disable the UI widgets for specifying NPV parameters.
+
+ :param enable: True to enable the widgets, else False to disable.
+ :type enable: bool
+ """
+ self.sb_num_years.setEnabled(enable)
+ self.sb_discount.setEnabled(enable)
+ self.tv_revenue_costs.setEnabled(enable)
diff --git a/src/cplus_plugin/lib/financials.py b/src/cplus_plugin/lib/financials.py
index d65537356..cbe86ef2f 100644
--- a/src/cplus_plugin/lib/financials.py
+++ b/src/cplus_plugin/lib/financials.py
@@ -57,7 +57,7 @@ def create_npv_pwls(
target_extent: str,
on_finish_func: typing.Callable = None,
on_removed_func: typing.Callable = None,
-):
+) -> typing.List:
"""Creates constant raster layers based on the normalized NPV values for
the specified activities.
@@ -90,11 +90,15 @@ def create_npv_pwls(
:param on_removed_func: Function to be executed when a disabled NPV PWL has
been removed.
:type on_finish_func: Callable
+
+ :returns: A list containing the processing results (as a dictionary) for
+ each successful run.
+ :rtype: list
"""
base_dir = settings_manager.get_value(Settings.BASE_DIR)
if not base_dir:
log(message=tr("No base directory for saving NPV PWLs."), info=False)
- return
+ return []
# Create NPV PWL subdirectory
FileUtils.create_npv_pwls_dir(base_dir)
@@ -105,6 +109,8 @@ def create_npv_pwls(
current_step = 0
multi_step_feedback.setCurrentStep(current_step)
+ results = []
+
for i, activity_npv in enumerate(npv_collection.mappings):
if feedback.isCanceled():
break
@@ -174,16 +180,19 @@ def create_npv_pwls(
"NUMBER": activity_npv.params.normalized_npv,
"OUTPUT": npv_pwl_path,
}
- processing.run(
+ res = processing.run(
"native:createconstantrasterlayer",
alg_params,
context=context,
feedback=multi_step_feedback,
onFinish=output_post_processing_func,
)
+ results.append(res)
except QgsProcessingException as ex:
err_tr = tr("Error creating NPV PWL")
log(f"{err_tr} {npv_pwl_path}")
current_step += 1
multi_step_feedback.setCurrentStep(current_step)
+
+ return results
diff --git a/src/cplus_plugin/models/financial.py b/src/cplus_plugin/models/financial.py
index a40132014..40f986eba 100644
--- a/src/cplus_plugin/models/financial.py
+++ b/src/cplus_plugin/models/financial.py
@@ -19,6 +19,7 @@ class NpvParameters:
normalized_npv: float = 0.0
# Each tuple contains 3 elements i.e. revenue, costs and discount rates
yearly_rates: typing.List[tuple] = dataclasses.field(default_factory=list)
+ manual_npv: bool = False
def __post_init__(self):
"""Set empty yearly rates for consistency."""
diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py
index ecf440fd4..d021a2242 100644
--- a/src/cplus_plugin/models/helpers.py
+++ b/src/cplus_plugin/models/helpers.py
@@ -27,6 +27,7 @@
NAME_ATTRIBUTE,
DESCRIPTION_ATTRIBUTE,
LAYER_TYPE_ATTRIBUTE,
+ MANUAL_NPV_ATTRIBUTE,
NPV_MAPPINGS_ATTRIBUTE,
MAX_VALUE_ATTRIBUTE,
MIN_VALUE_ATTRIBUTE,
@@ -452,6 +453,7 @@ def activity_npv_to_dict(activity_npv: ActivityNpv) -> dict:
ABSOLUTE_NPV_ATTRIBUTE: activity_npv.params.absolute_npv,
NORMALIZED_NPV_ATTRIBUTE: activity_npv.params.normalized_npv,
YEARLY_RATES_ATTRIBUTE: activity_npv.params.yearly_rates,
+ MANUAL_NPV_ATTRIBUTE: activity_npv.params.manual_npv,
ENABLED_ATTRIBUTE: activity_npv.enabled,
ACTIVITY_IDENTIFIER_PROPERTY: activity_npv.activity_id,
}
@@ -479,21 +481,25 @@ def create_activity_npv(activity_npv_dict: dict) -> typing.Optional[ActivityNpv]
if DISCOUNT_ATTRIBUTE in activity_npv_dict:
args.append(activity_npv_dict[DISCOUNT_ATTRIBUTE])
+ if len(args) < 2:
+ return None
+
+ kwargs = {}
+
if ABSOLUTE_NPV_ATTRIBUTE in activity_npv_dict:
- args.append(activity_npv_dict[ABSOLUTE_NPV_ATTRIBUTE])
+ kwargs[ABSOLUTE_NPV_ATTRIBUTE] = activity_npv_dict[ABSOLUTE_NPV_ATTRIBUTE]
if NORMALIZED_NPV_ATTRIBUTE in activity_npv_dict:
- args.append(activity_npv_dict[NORMALIZED_NPV_ATTRIBUTE])
+ kwargs[NORMALIZED_NPV_ATTRIBUTE] = activity_npv_dict[NORMALIZED_NPV_ATTRIBUTE]
- if len(args) < 4:
- return None
+ if MANUAL_NPV_ATTRIBUTE in activity_npv_dict:
+ kwargs[MANUAL_NPV_ATTRIBUTE] = activity_npv_dict[MANUAL_NPV_ATTRIBUTE]
+
+ npv_params = NpvParameters(*args, **kwargs)
- yearly_rates = []
if YEARLY_RATES_ATTRIBUTE in activity_npv_dict:
yearly_rates = activity_npv_dict[YEARLY_RATES_ATTRIBUTE]
-
- npv_params = NpvParameters(*args)
- npv_params.yearly_rates = yearly_rates
+ npv_params.yearly_rates = yearly_rates
npv_enabled = False
if ENABLED_ATTRIBUTE in activity_npv_dict:
diff --git a/src/cplus_plugin/ui/financial_pwl_dialog.ui b/src/cplus_plugin/ui/financial_pwl_dialog.ui
index 5dcdb7f26..d97e82ce5 100644
--- a/src/cplus_plugin/ui/financial_pwl_dialog.ui
+++ b/src/cplus_plugin/ui/financial_pwl_dialog.ui
@@ -14,10 +14,7 @@
NPV Priority Weighting Layer Manager
- -
-
-
- -
+
-
Qt::Horizontal
@@ -38,61 +35,58 @@
true
- false
+ true
-
-
-
-
- Number of years
-
-
-
- -
-
-
- 1
-
-
- 99
-
-
- 1
-
-
-
- -
-
+
-
+
Qt::Horizontal
- 294
+ 212
17
+ -
+
+
+ User-defined NPV
+
+
+
-
+
+
+ Number of years
+
+
+
+ -
Discount rate (%)
- -
-
-
- %
+
-
+
+
+ Qt::Horizontal
-
- 100.000000000000000
+
+
+ 294
+ 17
+
-
+
- -
-
+
-
+
Qt::Horizontal
@@ -104,28 +98,44 @@
- -
+
-
false
- -
+
-
Net present value per hectare
- -
-
-
- true
+
-
+
+
+ %
+
+
+ 100.000000000000000
- -
+
-
+
+
+ 1
+
+
+ 99
+
+
+ 1
+
+
+
+ -
Copy NPV
@@ -135,40 +145,41 @@
- -
-
-
- Qt::Horizontal
-
-
+
-
+
+
- 212
- 17
+ 100
+ 0
-
+
+ QAbstractSpinBox::NoButtons
+
+
+ US$
+
+
- -
-
-
-
- 16777215
- 20
-
+
-
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+ -
+
- <html><head/><body><p><span style=" font-weight:600;">Activities</span></p></body></html>
-
-
- Qt::RichText
+ Help
- -
+
-
@@ -233,14 +244,46 @@
- -
-
+
-
+
+
+
+ 16777215
+ 20
+
+
- Help
+ <html><head/><body><p><span style=" font-weight:600;">Activities</span></p></body></html>
+
+
+ Qt::RichText
+
+
+
+ -
+
+
+ Remove existing PWLs for disabled activity NPVs
- -
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 352
+ 20
+
+
+
+
+ -
+
+
+ -
@@ -348,30 +391,10 @@
- -
-
-
- QDialogButtonBox::Cancel|QDialogButtonBox::Ok
-
-
-
-
-
-
- Qt::Horizontal
-
-
-
- 352
- 20
-
-
-
-
- -
-
+
- Remove existing PWLs for disabled activity NPVs
+ [Interceptor]
diff --git a/test/test_financials.py b/test/test_financials.py
index 50cb31e35..737c45ef2 100644
--- a/test/test_financials.py
+++ b/test/test_financials.py
@@ -3,20 +3,51 @@
Unit tests for financial NPV computations.
"""
+import os
+import typing
from unittest import TestCase
-from cplus_plugin.lib.financials import compute_discount_value
-
-from model_data_for_testing import ACTIVITY_UUID_STR, get_activity_npv_collection
+from processing.core.Processing import Processing
+
+from qgis.core import (
+ QgsProcessingContext,
+ QgsProcessingFeedback,
+ QgsProcessingMultiStepFeedback,
+ QgsRasterLayer,
+)
+
+from cplus_plugin.conf import settings_manager, Settings
+from cplus_plugin.gui.qgis_cplus_main import QgisCplusMain
+from cplus_plugin.lib.financials import compute_discount_value, create_npv_pwls
+from cplus_plugin.utils import FileUtils
+
+from model_data_for_testing import (
+ ACTIVITY_UUID_STR,
+ get_activity_npv_collection,
+ get_ncs_pathways,
+)
from utilities_for_testing import get_qgis_app
QGIS_APP, CANVAS, IFACE, PARENT = get_qgis_app()
+class ConsoleFeedBack(QgsProcessingFeedback):
+ """Logs error information in the standard output device."""
+
+ _errors = []
+
+ def reportError(self, error, fatalError=False):
+ print(error)
+ self._errors.append(error)
+
+
class TestFinancialNpv(TestCase):
"""Tests for financial NPV computations."""
+ def setUp(self) -> None:
+ Processing.initialize()
+
def test_get_activity_npv_in_collection(self):
"""Test getting the activity NPV in the NPV collection."""
npv_collection = get_activity_npv_collection()
@@ -55,3 +86,92 @@ def test_npv_normalization_value(self):
activity_npv_1 = npv_collection.activity_npv(ACTIVITY_UUID_STR)
normalized_npv = round(activity_npv_1.params.normalized_npv, 4)
self.assertEqual(normalized_npv, 0.0259)
+
+ @classmethod
+ def _run_npv_pwl_creation(cls, on_finish_func: typing.Callable):
+ """Executes function for creating the NPV PWL then runs the user-defined
+ call back function once the NPV PWL processing function has successfully
+ finished.
+ """
+ npv_collection = get_activity_npv_collection()
+ npv_collection.update_computed_normalization_range()
+ _ = npv_collection.normalize_npvs()
+
+ npv_processing_context = QgsProcessingContext()
+ npv_feedback = ConsoleFeedBack()
+ npv_multi_step_feedback = QgsProcessingMultiStepFeedback(
+ len(npv_collection.mappings), npv_feedback
+ )
+
+ ncs_pathway = get_ncs_pathways(use_projected=True)[0]
+ reference_layer = ncs_pathway.to_map_layer()
+ reference_crs = reference_layer.crs()
+ reference_pixel_size = reference_layer.rasterUnitsPerPixelX()
+ reference_extent = reference_layer.extent()
+ reference_extent_str = (
+ f"{reference_extent.xMinimum()!s},"
+ f"{reference_extent.xMaximum()!s},"
+ f"{reference_extent.yMinimum()!s},"
+ f"{reference_extent.yMaximum()!s}"
+ )
+
+ base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
+ settings_base_dir = settings_manager.get_value(Settings.BASE_DIR)
+ if settings_base_dir != base_dir:
+ settings_manager.set_value(Settings.BASE_DIR, base_dir)
+ FileUtils.create_pwls_dir(base_dir)
+
+ create_npv_pwls(
+ npv_collection,
+ npv_processing_context,
+ npv_multi_step_feedback,
+ npv_feedback,
+ reference_crs.authid(),
+ reference_pixel_size,
+ reference_extent_str,
+ on_finish_func,
+ )
+
+ def test_create_npv_pwl(self):
+ """Test the creation of an NPV PWL raster layer."""
+
+ pwl_layer_path = None
+
+ def on_pwl_layer_created(activity_npv, pwl_path, algorithm, context, feedback):
+ nonlocal pwl_layer_path
+ assert pwl_path
+ pwl_layer_path = pwl_path
+
+ self._run_npv_pwl_creation(on_pwl_layer_created)
+
+ pwl_exists = os.path.exists(pwl_layer_path)
+ pwl_npv_layer = QgsRasterLayer(pwl_layer_path, "Test NPV PWL")
+
+ self.assertTrue(pwl_exists, msg="NPV PWL layer does not exists.")
+ self.assertTrue(pwl_npv_layer.isValid(), msg="NPV PWL raster is not valid.")
+
+ def test_npv_pwl_model_creation(self):
+ """Test the creation and saving of an NPV PWL data model."""
+
+ main_dock_widget = QgisCplusMain(IFACE, PARENT)
+
+ test_activity_npv = None
+
+ def proxy_npv_pwl_created(activity_npv, pwl_path, algorithm, context, feedback):
+ nonlocal test_activity_npv
+ nonlocal main_dock_widget
+ assert activity_npv
+ if test_activity_npv is None:
+ test_activity_npv = activity_npv
+
+ main_dock_widget.on_npv_pwl_created(
+ activity_npv, pwl_path, algorithm, context, feedback
+ )
+
+ self._run_npv_pwl_creation(proxy_npv_pwl_created)
+
+ npv_pwl = settings_manager.find_layer_by_name(test_activity_npv.base_name)
+
+ self.assertIsNotNone(
+ npv_pwl, msg="NPV PWL data model was not saved in the settings."
+ )