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." + )