From a8316e6b7a55163af8d073c56586115dc6218c52 Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Tue, 11 Nov 2025 16:49:55 +0100 Subject: [PATCH 1/6] Add constant rasters to API task detail --- .../api/scenario_task_api_client.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/cplus_plugin/api/scenario_task_api_client.py b/src/cplus_plugin/api/scenario_task_api_client.py index 58f7eda6..2c6e830f 100644 --- a/src/cplus_plugin/api/scenario_task_api_client.py +++ b/src/cplus_plugin/api/scenario_task_api_client.py @@ -19,6 +19,7 @@ from ..tasks import ScenarioAnalysisTask from ..utils import FileUtils, CustomJsonEncoder, todict from ..definitions.constants import NO_DATA_VALUE +from ..lib.constant_raster import constant_raster_registry def clean_filename(filename): @@ -354,6 +355,15 @@ def upload_layers(self) -> typing.Union[bool, None]: priority_layer.get() activity_pwl_uuids.add(priority_layer.get("uuid", "")) + constant_raster_components = constant_raster_registry.activity_components( + activity_identifier=str(activity.uuid) + ) + for constant_raster_component in constant_raster_components: + if not constant_raster_component.skip_raster and os.path.exists( + constant_raster_component.path + ): + items_to_check[constant_raster_component.path] = "constant_raster" + self._update_scenario_status( { "progress_text": "Checking Activity layers to be uploaded", @@ -594,6 +604,7 @@ def build_scenario_detail_json(self) -> None: priority_layer["layer_uuid"] = "" priority_layer["path"] = "" + activity_constant_rasters = {} for activity in old_scenario_dict["activities"]: activity["layer_type"] = 0 activity["path"] = "" @@ -633,6 +644,37 @@ def build_scenario_detail_json(self) -> None: activity["mask_uuids"] = mask_uuids activity["mask_paths"] = [] + constant_rasters = [] + constant_raster_components = constant_raster_registry.activity_components( + activity_identifier=activity["uuid"] + ) + for component in constant_raster_components: + constant_raster = { + "name": component.component.name, + "base_name": component.base_name, + "uuid": component.component_id, + "absolute": component.value_info.absolute, + "normalized": component.value_info.normalized, + "path": "", + "skip_raster": component.skip_raster + if os.path.exists(component.path) + else True, + } + + if not component.skip_raster and component.path: + path = component.path + if path.startswith("cplus://"): + constant_raster["uuid"] = path.replace("cplus://", "") + elif os.path.exists(path) and self.path_to_layer_mapping.get( + path, None + ): + constant_raster["uuid"] = self.path_to_layer_mapping.get(path)[ + "uuid" + ] + constant_rasters.append(constant_raster) + + activity_constant_rasters[activity["uuid"]] = constant_rasters + impact_matrix_dict = dict() impact_matrix = settings_manager.get_value( Settings.SCENARIO_IMPACT_MATRIX, dict() @@ -679,6 +721,7 @@ def build_scenario_detail_json(self) -> None: "studyarea_path": studyarea_path, "studyarea_layer_uuid": studyarea_layer_uuid, "relative_impact_matrix": impact_matrix_dict, + "activity_constant_rasters": activity_constant_rasters, } def __execute_scenario_analysis(self) -> None: From 14a586486bf0ee5bfd20dcff93d8ec60da744f7d Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Tue, 11 Nov 2025 16:52:20 +0100 Subject: [PATCH 2/6] Add util for normalizing rasters --- src/cplus_plugin/utils.py | 66 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 2dff2b64..13e9157d 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -30,6 +30,7 @@ QgsDistanceArea, QgsMessageLog, QgsProcessingFeedback, + QgsProcessingContext, QgsProject, QgsProcessing, QgsRasterLayer, @@ -1477,3 +1478,68 @@ def create_connectivity_raster( logs.append(traceback.format_exc()) return False, logs + + +def normalize_raster( + input_raster_path: str, + output_raster_path: str, + processing_context: QgsProcessingContext = None, + feedback: QgsProcessingFeedback = None, +): + """ + Create a normalized input raster + + :param input_raster_path: Input layer path + :type input_raster_path: str + + :param output_raster_path: Output layer path + :type output_raster_path: str + + :param processing_context: Qgis processing context + :type processing_context: QgsProcessingContext, default None + + :param feedback: Qgis processing feedback + :type feedback: QgsProcessingFeedback + """ + try: + input_raster_layer = QgsRasterLayer(input_raster_path, "Input Raster") + + if not input_raster_layer.isValid(): + return False, f"Invalid raster layer {input_raster_path}" + + provider = input_raster_layer.dataProvider() + band_statistics = provider.bandStatistics(1) + min_value = band_statistics.minimumValue + max_value = band_statistics.maximumValue + + if min_value is None or max_value is None: + return False, f"Raster layer has no valid statistics, {input_raster_path}" + + if min_value >= 0 and max_value <= 1: + return ( + True, + f"Layer is already normalized (min={min_value}, max={max_value})", + ) + + expression = f"(A - {min_value}) / ({max_value} - {min_value})" + + alg_params = { + "INPUT_A": input_raster_path, + "BAND_A": 1, + "FORMULA": expression, + "OPTIONS": "COMPRESS=DEFLATE|ZLEVEL=6|TILED=YES", + "OUTPUT": output_raster_path, + } + + result = processing.run( + "gdal:rastercalculator", + alg_params, + context=processing_context, + feedback=feedback, + ) + + if result.get("OUTPUT"): + return True, f"Normalized raster saved to : {output_raster_path}" + + except Exception as e: + return False, f"Problem normalizing pathways, {e} \n" From 3e44fff51aed9d22ad3c1a80ca436f108377703e Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Tue, 11 Nov 2025 17:06:46 +0100 Subject: [PATCH 3/6] Add support for investability analysis --- src/cplus_plugin/api/base.py | 3 + src/cplus_plugin/tasks.py | 170 +++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/cplus_plugin/api/base.py b/src/cplus_plugin/api/base.py index 8a734fda..36ac5b6b 100644 --- a/src/cplus_plugin/api/base.py +++ b/src/cplus_plugin/api/base.py @@ -72,6 +72,9 @@ def __create_activity(self, activity: dict, download_dict: list): if activity_filename in download_dict: activity["path"] = download_dict[activity_filename] + if activity.get("constant_rasters"): + del activity["constant_rasters"] + activity.pop("priority_layers", None) activity_obj = Activity(**activity) activity_obj.pathways = ncs_pathways diff --git a/src/cplus_plugin/tasks.py b/src/cplus_plugin/tasks.py index cdb70f0d..f92a5e57 100644 --- a/src/cplus_plugin/tasks.py +++ b/src/cplus_plugin/tasks.py @@ -34,6 +34,7 @@ SCENARIO_OUTPUT_FILE_NAME, DEFAULT_CRS_ID, ) +from .lib.constant_raster import constant_raster_registry from .models.base import ScenarioResult, Activity, NcsPathway from .resources import * from .utils import ( @@ -45,6 +46,7 @@ CustomJsonEncoder, todict, create_connectivity_raster, + normalize_raster, ) @@ -400,6 +402,9 @@ def run(self): self.analysis_activities, extent_string, temporary_output=not save_output ) + # Investability analysis + self.run_investability_analysis() + # The highest position tool analysis save_output = self.get_settings_value( Settings.HIGHEST_POSITION, default=True, setting_type=bool @@ -2799,6 +2804,171 @@ def create_activity_connectivity_layer(self, activity: Activity) -> str | None: self.cancel_task(e) return None + def run_investability_analysis(self) -> bool: + """Run activity investability analysis + + :returns: True if the task operation was successfully completed else False. + :rtype: bool + """ + if self.processing_cancelled: + return False + + self.set_status_message(tr("Calculating investability of the activities")) + + investable_activities = os.path.join( + self.scenario_directory, "investable_activities" + ) + FileUtils.create_new_dir(investable_activities) + + self.feedback = QgsProcessingFeedback() + self.feedback.progressChanged.connect(self.update_progress) + + try: + for activity in self.analysis_activities: + if activity.path is None or activity.path == "": + self.log_message( + f"Problem when running activity investability, " + f"there is no map layer for the activity {activity.name}" + ) + + return False + + if not os.path.exists(activity.path): + self.log_message( + f"Problem when running activity investability, " + f"the map layer for the activity {activity.name} does not exist" + ) + return False + + layers = [activity.path] + + activity_basename = Path(activity.path).stem + expression_items = [f'("{activity_basename}@1")'] + + constant_raster_components = ( + constant_raster_registry.activity_components( + activity_identifier=str(activity.id) + ) + ) + + constant_rasters = constant_raster_components + if constant_rasters is None: + constant_rasters = [] + + if self.processing_cancelled: + return False + + # Add connectivity layer + connectivity_path = self.create_activity_connectivity_layer( + activity=activity + ) + if connectivity_path and os.path.exists(connectivity_path): + constant_rasters.append( + { + "path": connectivity_path, + "name": "Connectivity layer", + "skip_raster": False, + } + ) + else: + self.log_message( + f"Invalid path for connectivity layer of activity {activity.name}" + ) + + if len(constant_rasters) == 0: + self.log_message( + f"No defined constant rasters, " + f"Skipping investability analysis for the activity {activity.name}" + ) + continue + + nr_constant_rasters = len(constant_rasters) + + for constant_raster in constant_rasters: + if "normalized" in constant_raster: + expression_items.append( + f"{constant_raster.get('normalized')} / {nr_constant_rasters}" + ) + else: + path = constant_raster.get("path", "") + if not os.path.exists(path): + self.log_message( + f"Invalid constant raster path {path}," + f"Skipping from the investability analysis for the activity {activity.name}" + ) + continue + + normalized_path = os.path.join( + f"{investable_activities}", + f"{Path(path).stem}_norm_{str(uuid.uuid4())[:4]}.tif", + ) + + if self.processing_cancelled: + return False + + ok, log = normalize_raster( + input_raster_path=path, + output_raster_path=normalized_path, + processing_context=self.processing_context, + feedback=self.feedback, + ) + self.log_message(log) + if not ok: + self.log_message( + f"Skipping {path} from the investability analysis for the activity {activity.name}" + ) + continue + + if os.path.exists(normalized_path): + path = normalized_path + + layers.append(path) + expression_items.append( + f'("{Path(path).stem}@1" / {nr_constant_rasters})' + ) + + output_path = os.path.join( + f"{investable_activities}", + f"{Path(activity.path).stem}_invest_{str(uuid.uuid4())[:4]}.tif", + ) + + alg_params = { + "CELLSIZE": 0, + "CRS": None, + "EXPRESSION": " + ".join(expression_items), + "LAYERS": layers, + "OUTPUT": output_path, + } + + self.log_message( + f" Used parameters for calculating investability for activity {activity.name}, " + f"{alg_params} \n" + ) + + if self.processing_cancelled: + return False + + result = processing.run( + "qgis:rastercalculator", + alg_params, + context=self.processing_context, + feedback=self.feedback, + ) + + if result.get("OUTPUT"): + activity.path = result.get("OUTPUT") + else: + self.log_message( + f"Problem calculating investability for activity {activity.name}" + ) + except Exception as e: + self.log_message(f"Problem calculating activity investability, {e} \n") + self.log_message(traceback.format_exc()) + self.cancel_task(e) + return False + + return True + def run_highest_position_analysis(self, temporary_output=False): """Runs the highest position analysis which is last step in scenario analysis. Uses the activities set by the current ongoing From c9949d939091ea6d13327d77b2a20185163ec656 Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Tue, 11 Nov 2025 17:26:54 +0100 Subject: [PATCH 4/6] Add support for investability analysis --- src/cplus_plugin/tasks.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/cplus_plugin/tasks.py b/src/cplus_plugin/tasks.py index f92a5e57..5d6330c6 100644 --- a/src/cplus_plugin/tasks.py +++ b/src/cplus_plugin/tasks.py @@ -2847,11 +2847,25 @@ def run_investability_analysis(self) -> bool: constant_raster_components = ( constant_raster_registry.activity_components( - activity_identifier=str(activity.id) + activity_identifier=str(activity.uuid) ) ) - constant_rasters = constant_raster_components + # Serialize the constant rasters + constant_rasters = [ + { + "name": component.base_name, + "uuid": component.component_id, + "absolute": component.value_info.absolute, + "normalized": component.value_info.normalized, + "path": component.path, + "skip_raster": component.skip_raster + if os.path.exists(component.path) + else True, + } + for component in constant_raster_components + ] + if constant_rasters is None: constant_rasters = [] @@ -2875,19 +2889,19 @@ def run_investability_analysis(self) -> bool: f"Invalid path for connectivity layer of activity {activity.name}" ) - if len(constant_rasters) == 0: + nr_constant_rasters = len(constant_rasters) + + if nr_constant_rasters == 0: self.log_message( f"No defined constant rasters, " f"Skipping investability analysis for the activity {activity.name}" ) continue - nr_constant_rasters = len(constant_rasters) - for constant_raster in constant_rasters: if "normalized" in constant_raster: expression_items.append( - f"{constant_raster.get('normalized')} / {nr_constant_rasters}" + str(constant_raster.get("normalized") / nr_constant_rasters) ) else: path = constant_raster.get("path", "") From 875435370f19135936684de710a8e22621623605 Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Wed, 12 Nov 2025 08:55:04 +0100 Subject: [PATCH 5/6] Resolved typo --- src/cplus_plugin/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 13e9157d..789d29a9 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -1542,4 +1542,4 @@ def normalize_raster( return True, f"Normalized raster saved to : {output_raster_path}" except Exception as e: - return False, f"Problem normalizing pathways, {e} \n" + return False, f"Problem normalizing raster, {e} \n" From 8df2bee7befc956431fa80984b9d693985e19cbd Mon Sep 17 00:00:00 2001 From: Robert Ohuru Date: Wed, 12 Nov 2025 09:01:43 +0100 Subject: [PATCH 6/6] Avoid division by zero when raster values is constant --- src/cplus_plugin/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 789d29a9..90589174 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -1521,6 +1521,12 @@ def normalize_raster( f"Layer is already normalized (min={min_value}, max={max_value})", ) + if min_value == max_value: + return ( + False, + f"Layer cannot be normalized because min value = {min_value} is same as max value = {max_value}", + ) + expression = f"(A - {min_value}) / ({max_value} - {min_value})" alg_params = {