From ba12dd9171123806b219de9a189e8cbfe0c24f06 Mon Sep 17 00:00:00 2001
From: Kahiu
Date: Wed, 12 Nov 2025 20:59:34 +0300
Subject: [PATCH 1/4] Show naturebase values in settings
---
src/cplus_plugin/api/layer_tasks.py | 5 +-
src/cplus_plugin/definitions/defaults.py | 4 +
.../gui/ncs_pathway_editor_dialog.py | 1 -
.../gui/settings/carbon_options.py | 74 +++++++++++++++++--
src/cplus_plugin/ui/carbon_settings.ui | 71 ++++++++++--------
5 files changed, 118 insertions(+), 37 deletions(-)
diff --git a/src/cplus_plugin/api/layer_tasks.py b/src/cplus_plugin/api/layer_tasks.py
index 6d142bfe..c60fab04 100644
--- a/src/cplus_plugin/api/layer_tasks.py
+++ b/src/cplus_plugin/api/layer_tasks.py
@@ -875,7 +875,10 @@ def finished(self, result: bool):
"Empty result set for zonal statistics calculation of Naturebase layers.",
info=False,
)
- result_info = ResultInfo(results, self.result.get("finished_at", ""))
+ sorted_results = []
+ if results:
+ sorted_results = sorted(results, key=lambda d: d["layer_name"])
+ result_info = ResultInfo(sorted_results, self.result.get("finished_at", ""))
settings_manager.save_nature_base_zonal_stats(result_info)
else:
self.set_status_message("Zonal statistics task failed or was cancelled")
diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py
index 130bd17a..c5587e54 100644
--- a/src/cplus_plugin/definitions/defaults.py
+++ b/src/cplus_plugin/definitions/defaults.py
@@ -50,6 +50,10 @@
RESTORE_CARBON_IMPACT_HEADER = "C.I. (Restore)"
TOTAL_CARBON_IMPACT_HEADER = "Total C.I."
+# Naturebase carbon impact table headers
+LAYER_NAME_HEADER = "Layer Name"
+CARBON_IMPACT_PER_HA_HEADER = "tCO2e/ha"
+
ICON_PATH = ":/plugins/cplus_plugin/icon.svg"
REPORT_SETTINGS_ICON_PATH = str(
os.path.normpath(
diff --git a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py
index e1d37ebc..16efa6e3 100644
--- a/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py
+++ b/src/cplus_plugin/gui/ncs_pathway_editor_dialog.py
@@ -110,7 +110,6 @@ def __init__(self, parent=None, ncs_pathway=None, excluded_names=None):
# Naturebase carbon impact reference
carbon_impact_info = settings_manager.get_nature_base_zonal_stats()
if carbon_impact_info:
- # Manage page
self.cbo_naturebase_carbon_mng.addItem("")
self.cbo_naturebase_carbon_rst.addItem("")
for impact in carbon_impact_info.result_collection:
diff --git a/src/cplus_plugin/gui/settings/carbon_options.py b/src/cplus_plugin/gui/settings/carbon_options.py
index 0970f35f..ef3346dd 100644
--- a/src/cplus_plugin/gui/settings/carbon_options.py
+++ b/src/cplus_plugin/gui/settings/carbon_options.py
@@ -13,11 +13,13 @@
from qgis.PyQt import QtCore
from qgis.PyQt.QtGui import (
QIcon,
- QShowEvent,
QPixmap,
+ QShowEvent,
+ QStandardItem,
+ QStandardItemModel,
)
-from qgis.PyQt.QtWidgets import QButtonGroup, QWidget
+from qgis.PyQt.QtWidgets import QButtonGroup, QHeaderView, QWidget
from ...api.base import ApiRequestStatus
from ...api.carbon import (
@@ -29,12 +31,18 @@
settings_manager,
Settings,
)
-from ...definitions.constants import CPLUS_OPTIONS_KEY, CARBON_OPTIONS_KEY
+from ...definitions.constants import (
+ CPLUS_OPTIONS_KEY,
+ CARBON_OPTIONS_KEY,
+ LAYER_NAME_ATTRIBUTE,
+ MEAN_VALUE_ATTRIBUTE,
+)
from ...definitions.defaults import (
+ CARBON_IMPACT_PER_HA_HEADER,
OPTIONS_TITLE,
CARBON_OPTIONS_TITLE,
CARBON_SETTINGS_ICON_PATH,
- MAX_CARBON_IMPACT_MANAGE,
+ LAYER_NAME_HEADER,
)
from ...models.base import DataSourceType
from ...utils import FileUtils, tr
@@ -45,6 +53,39 @@
)
+class NaturebaseCarbonImpactModel(QStandardItemModel):
+ """Model for displaying carbon impact values in a table view."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setColumnCount(2)
+ self.setHorizontalHeaderLabels([LAYER_NAME_HEADER, CARBON_IMPACT_PER_HA_HEADER])
+
+ def _readonly_item(self, text: str = "") -> QStandardItem:
+ """Helper to create a non-editable QStandardItem with
+ given display text.
+ """
+ item = QStandardItem(text)
+ item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
+
+ return item
+
+ def add_row(self, layer_name: str, carbon_impact: float):
+ """Adds a row with the layer details to the model.
+
+ :param layer_name: Name of the layer.
+ :type layer_name: str
+
+ :param carbon_impact: Value of the carbon impact.
+ :type carbon_impact: float
+ """
+ name_item = self._readonly_item(str(layer_name))
+ carbon_item = self._readonly_item(str(carbon_impact))
+ carbon_item.setData(carbon_impact, QtCore.Qt.UserRole)
+
+ self.appendRow([name_item, carbon_item])
+
+
class CarbonSettingsWidget(QgsOptionsPageWidget, Ui_CarbonSettingsWidget):
"""Carbon settings widget."""
@@ -114,8 +155,29 @@ def __init__(self, parent=None):
self.cbo_biomass.setFilters(qgis.core.QgsMapLayerProxyModel.Filter.RasterLayer)
# Naturebase carbon impact
- # Temp disable until functionality is complete
- self.gb_carbon_management.setVisible(False)
+ self._carbon_impact_model = NaturebaseCarbonImpactModel()
+ self.tv_naturebase_carbon_impact.setModel(self._carbon_impact_model)
+ self.tv_naturebase_carbon_impact.setSortingEnabled(True)
+
+ header = self.tv_naturebase_carbon_impact.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.Stretch)
+ header.setSectionsClickable(True)
+ header.setSortIndicatorShown(True)
+
+ carbon_impact_info = settings_manager.get_nature_base_zonal_stats()
+ if carbon_impact_info:
+ for impact in carbon_impact_info.result_collection:
+ layer_name = impact.get(LAYER_NAME_ATTRIBUTE)
+ mean_value = impact.get(MEAN_VALUE_ATTRIBUTE) or 0.0
+ self._carbon_impact_model.add_row(layer_name, mean_value)
+
+ updated_str = (
+ f'
'
+ f'{self.tr("Last updated")}: {carbon_impact_info.to_local_time()}:
'
+ )
+ self.lbl_last_updated_carbon_impact.setText(updated_str)
+
+ self.tv_naturebase_carbon_impact.sortByColumn(0, QtCore.Qt.AscendingOrder)
def apply(self) -> None:
"""This is called on OK click in the QGIS options panel."""
diff --git a/src/cplus_plugin/ui/carbon_settings.ui b/src/cplus_plugin/ui/carbon_settings.ui
index c8383f9e..46e6f05b 100644
--- a/src/cplus_plugin/ui/carbon_settings.ui
+++ b/src/cplus_plugin/ui/carbon_settings.ui
@@ -228,7 +228,7 @@
-
- false
+ true
@@ -246,17 +246,14 @@
Naturebase carbon impact
-
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
- QAbstractItemView::SelectRows
+
-
+
+
+ <html><head/><body><p><span style=" color:#6a6a6a;">Last updated:</span></p></body></html>
- -
+
-
@@ -272,42 +269,58 @@
- -
-
-
- <html><head/><body><p>Mean values fetched from online Naturebase layers are shown below:</p></body></html>
+
-
+
+
+ Qt::Horizontal
+
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+ QAbstractItemView::SingleSelection
+
+
+ QAbstractItemView::SelectRows
+
+ false
+
- -
+
-
Auto-refresh on extents changed in Step 1 (network-resource intensive)
- -
-
+
-
+
- <html><head/><body><p><span style=" color:#6a6a6a;">Last updated:</span></p></body></html>
+ <html><head/><body><p>Mean values fetched from the online Naturebase layers are shown below:</p></body></html>
- -
-
+
-
+
Qt::Horizontal
-
- QSizePolicy::Minimum
-
-
-
- 40
- 20
-
-
-
+
From 1294479969a682a36a5bc8c9e54c427c58f9dc9b Mon Sep 17 00:00:00 2001
From: Kahiu
Date: Sat, 15 Nov 2025 10:59:44 +0300
Subject: [PATCH 2/4] Fix issues with fetching zonal stats results.
---
src/cplus_plugin/api/layer_tasks.py | 46 ++++--
src/cplus_plugin/api/request.py | 31 ++++
src/cplus_plugin/conf.py | 1 +
.../gui/settings/carbon_options.py | 109 ++++++++++++-
src/cplus_plugin/models/base.py | 5 +-
src/cplus_plugin/ui/carbon_settings.ui | 153 ++++++++++++------
6 files changed, 283 insertions(+), 62 deletions(-)
diff --git a/src/cplus_plugin/api/layer_tasks.py b/src/cplus_plugin/api/layer_tasks.py
index c60fab04..c4e34e3d 100644
--- a/src/cplus_plugin/api/layer_tasks.py
+++ b/src/cplus_plugin/api/layer_tasks.py
@@ -39,7 +39,13 @@
get_layer_type,
convert_size,
)
-from .request import CplusApiPooling, CplusApiRequest, CplusApiUrl, JOB_COMPLETED_STATUS
+from .request import (
+ CplusApiPooling,
+ CplusApiRequest,
+ CplusApiRequestError,
+ CplusApiUrl,
+ JOB_COMPLETED_STATUS,
+)
from ..definitions.defaults import DEFAULT_CRS_ID
@@ -744,7 +750,6 @@ class CalculateNatureBaseZonalStatsTask(QgsTask):
"""
status_message_changed = QtCore.pyqtSignal(str)
- progress_changed = QtCore.pyqtSignal(float)
results_ready = QtCore.pyqtSignal(object)
task_finished = QtCore.pyqtSignal(bool)
@@ -781,6 +786,8 @@ def run(self) -> bool:
"""Initiate the zonal statistics calculation and poll until
completed.
+ Use `poll_once` method for non-blocking iterations.
+
:returns: True if the calculation process succeeded or
False it if failed.
:rtype: bool
@@ -827,12 +834,31 @@ def run(self) -> bool:
f"Zonal statistics calculation started, task id: {task_uuid}"
)
- pooling = self.request.fetch_zonal_statistics_progress(task_uuid)
+ polling = self.request.fetch_zonal_statistics_progress(task_uuid)
# Repeatedly poll until final status
+ status = True
try:
- while not self.isCanceled():
- response = pooling.results() or {}
+ while True:
+ if self.isCanceled():
+ polling.cancelled = True
+ status = False
+ break
+
+ try:
+ response = polling.poll_once() or {}
+ except CplusApiRequestError as ex:
+ log(f"Polling error: {ex}", info=False)
+ status = False
+ break
+ except Exception as ex:
+ log(
+ f"Error while polling zonal statistics progress: {ex}",
+ info=False,
+ )
+ time.sleep(self.polling_interval)
+ continue
+
status_str = response.get("status")
progress = response.get("progress", 0.0)
try:
@@ -845,18 +871,20 @@ def run(self) -> bool:
progress_value = 0.0
self.setProgress(int(progress_value))
- self.progress_changed.emit(progress_value)
self.set_status_message(
f"Zonal statistics: {status_str} ({progress_value:.1f}%)"
)
+
if status_str in CplusApiPooling.FINAL_STATUS_LIST:
+ if status_str != JOB_COMPLETED_STATUS:
+ status = False
self.result = response
break
- # Pooling.results already sleeps between attempts, but keep guard
+ # Sleep between poll iterations so that we can control the frequency
time.sleep(self.polling_interval)
- return True
+ return status
except Exception as ex:
log(
f"Error while polling zonal statistics progress: {ex}",
@@ -865,7 +893,7 @@ def run(self) -> bool:
return False
def finished(self, result: bool):
- """Emit signals and optionally persist results in settings."""
+ """Emit signals and persist results in settings."""
if result and self.result:
results = self.result.get("results", [])
self.results_ready.emit(results)
diff --git a/src/cplus_plugin/api/request.py b/src/cplus_plugin/api/request.py
index 654eb2e0..ffd74ef2 100644
--- a/src/cplus_plugin/api/request.py
+++ b/src/cplus_plugin/api/request.py
@@ -129,6 +129,37 @@ def __call_api(self) -> typing.Tuple[dict, int]:
return self.context.get(self.url)
return self.context.post(self.url, self.data)
+ def poll_once(self) -> dict:
+ """Perform a single API call to the network resource
+ and returns the response dict.
+
+ This does not sleep or recurse. It increments the
+ retry counter and enforces cancellation / timeout rules.
+ Use this from external loops to control the
+ loop frequency and update dependencies
+ after each response.
+
+ :returns: Dictionary containing the response details.
+ :rtype: dict
+ """
+ if self.cancelled:
+ return {"status": JOB_CANCELLED_STATUS}
+
+ if self.limit != -1 and self.current_repeat >= self.limit:
+ raise CplusApiRequestError("Request Timeout when fetching status!")
+
+ self.current_repeat += 1
+
+ response, status_code = self.__call_api()
+ if status_code != 200:
+ error_detail = response.get("detail", "Unknown Error!")
+ raise CplusApiRequestError(f"{status_code} - {error_detail}")
+
+ if self.on_response_fetched:
+ self.on_response_fetched(response)
+
+ return response
+
def results(self) -> dict:
"""Fetch the results from API every X seconds and stop when status is in the final status list.
diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py
index 373ad581..595d3f9a 100644
--- a/src/cplus_plugin/conf.py
+++ b/src/cplus_plugin/conf.py
@@ -275,6 +275,7 @@ class Settings(enum.Enum):
# Naturebase mean zonal statistics
NATURE_BASE_MEAN_ZONAL_STATS = "nature_base_zonal_stats/mean"
+ AUTO_REFRESH_NATURE_BASE_ZONAL_STATS = "nature_base_zonal_stats/auto_refresh"
# Constant Rasters Dialog
CONSTANT_RASTERS_DIALOG_ACTIVITY_TYPE = (
diff --git a/src/cplus_plugin/gui/settings/carbon_options.py b/src/cplus_plugin/gui/settings/carbon_options.py
index ef3346dd..99a23cb4 100644
--- a/src/cplus_plugin/gui/settings/carbon_options.py
+++ b/src/cplus_plugin/gui/settings/carbon_options.py
@@ -10,7 +10,7 @@
from qgis.gui import QgsFileWidget, QgsMessageBar, QgsOptionsPageWidget
from qgis.gui import QgsOptionsWidgetFactory
from qgis.PyQt import uic
-from qgis.PyQt import QtCore
+from qgis.PyQt import QtCore, sip
from qgis.PyQt.QtGui import (
QIcon,
QPixmap,
@@ -26,6 +26,7 @@
start_irrecoverable_carbon_download,
get_downloader_task,
)
+from ...api.layer_tasks import calculate_zonal_stats_task
from ...conf import (
settings_manager,
@@ -85,6 +86,12 @@ def add_row(self, layer_name: str, carbon_impact: float):
self.appendRow([name_item, carbon_item])
+ def remove_all_rows(self) -> None:
+ """Remove all rows from the model while preserving the column headers."""
+ row_count = self.rowCount()
+ if row_count > 0:
+ self.removeRows(0, row_count)
+
class CarbonSettingsWidget(QgsOptionsPageWidget, Ui_CarbonSettingsWidget):
"""Carbon settings widget."""
@@ -155,6 +162,7 @@ def __init__(self, parent=None):
self.cbo_biomass.setFilters(qgis.core.QgsMapLayerProxyModel.Filter.RasterLayer)
# Naturebase carbon impact
+ self.zonal_stats_task = None
self._carbon_impact_model = NaturebaseCarbonImpactModel()
self.tv_naturebase_carbon_impact.setModel(self._carbon_impact_model)
self.tv_naturebase_carbon_impact.setSortingEnabled(True)
@@ -163,7 +171,15 @@ def __init__(self, parent=None):
header.setSectionResizeMode(QHeaderView.Stretch)
header.setSectionsClickable(True)
header.setSortIndicatorShown(True)
+ self.load_carbon_impact()
+ self.tv_naturebase_carbon_impact.sortByColumn(0, QtCore.Qt.AscendingOrder)
+
+ self.btn_reload_carbon_impact.clicked.connect(
+ self._on_reload_naturebase_carbon_impact
+ )
+ def load_carbon_impact(self):
+ """Load carbon impact info based on the latest values in settings."""
carbon_impact_info = settings_manager.get_nature_base_zonal_stats()
if carbon_impact_info:
for impact in carbon_impact_info.result_collection:
@@ -171,13 +187,11 @@ def __init__(self, parent=None):
mean_value = impact.get(MEAN_VALUE_ATTRIBUTE) or 0.0
self._carbon_impact_model.add_row(layer_name, mean_value)
- updated_str = (
+ updated_date_str = (
f''
- f'{self.tr("Last updated")}: {carbon_impact_info.to_local_time()}:
'
+ f'{self.tr("Last updated")}: {carbon_impact_info.to_local_time()}'
)
- self.lbl_last_updated_carbon_impact.setText(updated_str)
-
- self.tv_naturebase_carbon_impact.sortByColumn(0, QtCore.Qt.AscendingOrder)
+ self.lbl_last_updated_carbon_impact.setText(updated_date_str)
def apply(self) -> None:
"""This is called on OK click in the QGIS options panel."""
@@ -218,6 +232,12 @@ def save_settings(self) -> None:
self.fw_biomass.filePath(),
)
+ # Carbon impact
+ settings_manager.set_value(
+ Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS,
+ self.cb_auto_refresh_carbon_impact.isChecked(),
+ )
+
def load_settings(self):
"""Loads the settings and displays it in the UI."""
# Irrecoverable carbon
@@ -269,7 +289,13 @@ def load_settings(self):
settings_manager.get_value(Settings.STORED_CARBON_BIOMASS_PATH, default="")
)
- # Carbon impact - manage
+ # Carbon impact
+ auto_refresh = settings_manager.get_value(
+ Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS,
+ default=False,
+ setting_type=bool,
+ )
+ self.cb_auto_refresh_carbon_impact.setChecked(auto_refresh)
def showEvent(self, event: QShowEvent) -> None:
"""Show event being called. This will display the plugin settings.
@@ -481,6 +507,75 @@ def _on_biomass_layer_changed(self, layer: qgis.core.QgsMapLayer):
if layer is not None:
self.fw_biomass.setFilePath(layer.source())
+ def _on_reload_naturebase_carbon_impact(self):
+ """Slot raised to initiate the fetching of Naturebase zonal stats."""
+ # Disconnect any existing zonal stats receivers
+ if self.zonal_stats_task and not sip.isdeleted(self.zonal_stats_task):
+ self.zonal_stats_task.statusChanged.disconnect(
+ lambda s: self.reload_zonal_stats_task_status()
+ )
+ self.zonal_stats_task.taskCompleted.disconnect(
+ self._on_zonal_stats_complete_or_error
+ )
+ self.zonal_stats_task.taskTerminated.disconnect(
+ self._on_zonal_stats_complete_or_error
+ )
+
+ self.zonal_stats_task = calculate_zonal_stats_task()
+
+ # Reconnect signals
+ if self.zonal_stats_task:
+ self.zonal_stats_task.statusChanged.connect(
+ lambda s: self.reload_zonal_stats_task_status()
+ )
+ self.zonal_stats_task.progressChanged.connect(
+ lambda s: self.reload_zonal_stats_task_status()
+ )
+ self.zonal_stats_task.taskCompleted.connect(
+ self._on_zonal_stats_complete_or_error
+ )
+ self.zonal_stats_task.taskTerminated.connect(
+ self._on_zonal_stats_complete_or_error
+ )
+
+ self.btn_reload_carbon_impact.setEnabled(False)
+ self.tv_naturebase_carbon_impact.setEnabled(False)
+
+ # Update the latest status
+ self.reload_zonal_stats_task_status()
+
+ def _on_zonal_stats_complete_or_error(self):
+ """Re-enable controls and refresh table view if applicable."""
+ self.btn_reload_carbon_impact.setEnabled(True)
+ self.tv_naturebase_carbon_impact.setEnabled(True)
+ if self.zonal_stats_task.status() == qgis.core.QgsTask.TaskStatus.Complete:
+ self.load_carbon_impact()
+
+ def reload_zonal_stats_task_status(self):
+ """Update icon and description of zonal stats task."""
+ icon_path = ""
+ description = ""
+ if self.zonal_stats_task:
+ status = self.zonal_stats_task.status()
+ if status == qgis.core.QgsTask.TaskStatus.OnHold:
+ icon_path = FileUtils.get_icon_path("mIndicatorTemporal.svg")
+ description = self.tr("Not started")
+ elif status == qgis.core.QgsTask.TaskStatus.Queued:
+ icon_path = FileUtils.get_icon_path("mIndicatorTemporal.svg")
+ description = self.tr("Queued")
+ elif status == qgis.core.QgsTask.TaskStatus.Running:
+ icon_path = FileUtils.get_icon_path("progress-indicator.svg")
+ description = f"{self.tr('Running')} ({int(self.zonal_stats_task.progress())}%)..."
+ elif status == qgis.core.QgsTask.TaskStatus.Complete:
+ icon_path = FileUtils.get_icon_path("mIconSuccess.svg")
+ description = self.tr("Completed")
+ elif status == qgis.core.QgsTask.TaskStatus.Terminated:
+ icon_path = FileUtils.get_icon_path("mIconWarning.svg")
+ description = self.tr("Terminated")
+
+ self.lbl_carbon_impact_status_icon.svg_path = icon_path
+ self.lbl_carbon_impact_status_description.setText(description)
+
class CarbonOptionsFactory(QgsOptionsWidgetFactory):
"""Factory for defining CPLUS carbon settings."""
diff --git a/src/cplus_plugin/models/base.py b/src/cplus_plugin/models/base.py
index 24831418..6d211b06 100644
--- a/src/cplus_plugin/models/base.py
+++ b/src/cplus_plugin/models/base.py
@@ -736,4 +736,7 @@ def to_local_time(self) -> str:
if not updated_date_time.isValid():
return ""
- return QLocale.system().toString(updated_date_time, QLocale.LongFormat)
+ updated_date_time.setTimeSpec(Qt.UTC)
+ local_date_time = updated_date_time.toLocalTime()
+
+ return QLocale.system().toString(local_date_time, QLocale.LongFormat)
diff --git a/src/cplus_plugin/ui/carbon_settings.ui b/src/cplus_plugin/ui/carbon_settings.ui
index 46e6f05b..49560342 100644
--- a/src/cplus_plugin/ui/carbon_settings.ui
+++ b/src/cplus_plugin/ui/carbon_settings.ui
@@ -6,7 +6,7 @@
0
0
- 555
+ 579
726
@@ -246,44 +246,59 @@
Naturebase carbon impact
- -
-
-
- <html><head/><body><p><span style=" color:#6a6a6a;">Last updated:</span></p></body></html>
+
-
+
+
+ 0
-
-
- -
-
-
-
- 0
- 0
-
-
-
- Manually reload carbon impact values for Naturebase layers
-
-
- Reload
-
-
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Auto-refresh on extents changed in Step 1
+
+
+
+ -
+
+
+ <html><head/><body><p><span style=" font-style:italic;">(more network-resource intensive)</span></p></body></html>
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
- -
-
+
-
+
Qt::Horizontal
-
- QSizePolicy::Minimum
-
-
-
- 40
- 20
-
+
+
+ -
+
+
+ <html><head/><body><p>Mean values fetched from the online Naturebase layers are shown below:</p></body></html>
-
+
-
@@ -301,26 +316,74 @@
- -
-
+
-
+
- Auto-refresh on extents changed in Step 1 (network-resource intensive)
+ <html><head/><body><p><span style=" color:#6a6a6a;">Last updated:</span></p></body></html>
- -
-
-
- <html><head/><body><p>Mean values fetched from the online Naturebase layers are shown below:</p></body></html>
-
-
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+ Manually reload carbon impact values for Naturebase layers
+
+
+ Reload
+
+
+
+ -
+
+
+
+ 16
+ 16
+
+
+
+
+ 24
+ 24
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
- -
-
+
-
+
Qt::Horizontal
-
+
+ QSizePolicy::Minimum
+
+
+
+ 40
+ 20
+
+
+
From 434143ffae158bbe358b9858fecc1e2e3731c7db Mon Sep 17 00:00:00 2001
From: Kahiu
Date: Mon, 17 Nov 2025 11:38:21 +0300
Subject: [PATCH 3/4] Incorporate auto refresh of zonal stats.
---
src/cplus_plugin/api/layer_tasks.py | 60 ++++++++++++++++++-
src/cplus_plugin/gui/qgis_cplus_main.py | 26 +++++++-
.../gui/settings/carbon_options.py | 1 +
3 files changed, 83 insertions(+), 4 deletions(-)
diff --git a/src/cplus_plugin/api/layer_tasks.py b/src/cplus_plugin/api/layer_tasks.py
index c4e34e3d..e3e0d821 100644
--- a/src/cplus_plugin/api/layer_tasks.py
+++ b/src/cplus_plugin/api/layer_tasks.py
@@ -20,6 +20,7 @@
QgsFileDownloader,
QgsCoordinateTransform,
QgsCoordinateReferenceSystem,
+ QgsVectorLayer,
)
from qgis.PyQt import QtCore
@@ -38,6 +39,7 @@
compress_raster,
get_layer_type,
convert_size,
+ transform_extent,
)
from .request import (
CplusApiPooling,
@@ -775,12 +777,62 @@ def _get_bbox_for_request(self) -> str:
return str(self.bbox)
- # Otherwise get saved scenario extent
- extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None)
+ # Otherwise get saved extents from settings
+ clip_to_studyarea = settings_manager.get_value(
+ Settings.CLIP_TO_STUDYAREA, default=False, setting_type=bool
+ )
+ if clip_to_studyarea:
+ # From vector layer
+ study_area_path = settings_manager.get_value(
+ Settings.STUDYAREA_PATH, default="", setting_type=str
+ )
+ if not study_area_path or not os.path.exists(study_area_path):
+ log("Path for determining layer extent is invalid.", info=False)
+ return ""
+
+ aoi_layer = QgsVectorLayer(study_area_path, "AOI Layer")
+ if not aoi_layer.isValid():
+ log("AOI layer is invalid.", info=False)
+ return ""
+
+ source_crs = aoi_layer.crs()
+ if not source_crs:
+ log("CRS of AOI layer is undefined.", info=False)
+ return ""
+
+ aoi_extent = aoi_layer.extent()
+ if not aoi_extent:
+ log("Extent of AOI layer is undefined.", info=False)
+ return ""
+
+ # Reproject extent if required
+ destination_crs = QgsCoordinateReferenceSystem("EPSG:4326")
+ if source_crs != destination_crs:
+ aoi_extent = transform_extent(aoi_extent, source_crs, destination_crs)
+
+ extent = [
+ aoi_extent.xMinimum(),
+ aoi_extent.yMinimum(),
+ aoi_extent.xMaximum(),
+ aoi_extent.yMaximum(),
+ ]
+ else:
+ # From explicit extent definition
+ settings_extent = settings_manager.get_value(
+ Settings.SCENARIO_EXTENT, default=None
+ )
+ # Ensure in minX, minY, maxX, maxY format
+ extent = [
+ float(settings_extent[0]),
+ float(settings_extent[2]),
+ float(settings_extent[1]),
+ float(settings_extent[3]),
+ ]
+
if not extent or len(extent) < 4:
raise ValueError("Scenario extent is not defined or invalid.")
- return f"{float(extent[0])},{float(extent[2])},{float(extent[1])},{float(extent[3])}"
+ return f"{extent[0]},{extent[1]},{extent[2]},{extent[3]}"
def run(self) -> bool:
"""Initiate the zonal statistics calculation and poll until
@@ -794,6 +846,7 @@ def run(self) -> bool:
"""
try:
bbox_str = self._get_bbox_for_request()
+ log(f"BBOX for Zonal stats request: {bbox_str}")
except Exception as e:
log(f"Invalid bbox: {e}")
return False
@@ -846,6 +899,7 @@ def run(self) -> bool:
break
try:
+ # Use poll once which is non-blocking
response = polling.poll_once() or {}
except CplusApiRequestError as ex:
log(f"Polling error: {ex}", info=False)
diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py
index 1d8dbeaf..dbedfaed 100644
--- a/src/cplus_plugin/gui/qgis_cplus_main.py
+++ b/src/cplus_plugin/gui/qgis_cplus_main.py
@@ -57,7 +57,7 @@
from .progress_dialog import OnlineProgressDialog, ReportProgressDialog, ProgressDialog
from ..trends_earth import auth
from ..api.scenario_task_api_client import ScenarioAnalysisTaskApiClient
-from ..api.layer_tasks import FetchDefaultLayerTask
+from ..api.layer_tasks import calculate_zonal_stats_task, FetchDefaultLayerTask
from ..api.scenario_history_tasks import (
FetchScenarioHistoryTask,
FetchScenarioOutputTask,
@@ -404,6 +404,9 @@ def prepare_input(self):
self.scenario_name.textChanged.connect(self.save_scenario)
self.scenario_description.textChanged.connect(self.save_scenario)
self.extent_box.extentChanged.connect(self.save_scenario)
+ self.extent_box.extentChanged.connect(
+ lambda s: self.update_naturebase_carbon_impact()
+ )
icon_pixmap = QtGui.QPixmap(ICON_PATH)
self.icon_la.setPixmap(icon_pixmap)
@@ -553,6 +556,9 @@ def on_aoi_source_changed(self, button_id: int, toggled: bool):
self.save_scenario()
+ # Check and fetch carbon impact for the current extent
+ self.update_naturebase_carbon_impact()
+
def _on_studyarea_file_changed(self):
"""Slot raised to when the area of interest is selected from a local file system."""
data_dir = settings_manager.get_value(Settings.LAST_DATA_DIR, "")
@@ -580,6 +586,9 @@ def _on_studyarea_file_changed(self):
self.save_scenario()
+ # Check and fetch carbon impact for the current extent
+ self.update_naturebase_carbon_impact()
+
def _on_studyarea_layer_changed(self, layer):
"""Slot raised to when the area of interest is selected from a map layers."""
if layer is not None:
@@ -590,6 +599,9 @@ def _on_studyarea_layer_changed(self, layer):
self.save_scenario()
+ # Check and fetch carbon impact for the current extent
+ self.update_naturebase_carbon_impact()
+
def can_clip_to_studyarea(self) -> bool:
"""Return true if clipping layers by study area is selected"""
clip_to_studyarea = False
@@ -601,6 +613,18 @@ def can_clip_to_studyarea(self) -> bool:
clip_to_studyarea = True
return clip_to_studyarea
+ def update_naturebase_carbon_impact(self):
+ """Fetch the naturebase zonal stats based on the current extent."""
+ auto_refresh = settings_manager.get_value(
+ Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS,
+ default=False,
+ setting_type=bool,
+ )
+ if not auto_refresh:
+ return
+
+ _ = calculate_zonal_stats_task()
+
def get_studyarea_path(self) -> str:
"""Return the path of the study area
diff --git a/src/cplus_plugin/gui/settings/carbon_options.py b/src/cplus_plugin/gui/settings/carbon_options.py
index 99a23cb4..4c64ed56 100644
--- a/src/cplus_plugin/gui/settings/carbon_options.py
+++ b/src/cplus_plugin/gui/settings/carbon_options.py
@@ -180,6 +180,7 @@ def __init__(self, parent=None):
def load_carbon_impact(self):
"""Load carbon impact info based on the latest values in settings."""
+ self._carbon_impact_model.remove_all_rows()
carbon_impact_info = settings_manager.get_nature_base_zonal_stats()
if carbon_impact_info:
for impact in carbon_impact_info.result_collection:
From a4031c08653c04ddf772dffbc1060d6887a1713d Mon Sep 17 00:00:00 2001
From: Kahiu
Date: Mon, 17 Nov 2025 12:11:59 +0300
Subject: [PATCH 4/4] Remove UI for irrecoverable carbon URL.
---
.../gui/settings/carbon_options.py | 12 ++-----
src/cplus_plugin/ui/carbon_settings.ui | 35 ++++++-------------
2 files changed, 14 insertions(+), 33 deletions(-)
diff --git a/src/cplus_plugin/gui/settings/carbon_options.py b/src/cplus_plugin/gui/settings/carbon_options.py
index 4c64ed56..45effd32 100644
--- a/src/cplus_plugin/gui/settings/carbon_options.py
+++ b/src/cplus_plugin/gui/settings/carbon_options.py
@@ -205,9 +205,6 @@ def save_settings(self) -> None:
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(),
@@ -258,11 +255,6 @@ def load_settings(self):
)
# 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=""
@@ -371,7 +363,9 @@ def validate_irrecoverable_carbon_url(self) -> bool:
well-formed.
:rtype: bool
"""
- dataset_url = self.txt_ic_url.text()
+ dataset_url = settings_manager.get_value(
+ Settings.IRRECOVERABLE_CARBON_ONLINE_SOURCE, default="", setting_type=str
+ )
if not dataset_url:
self.message_bar.pushWarning(
tr("CPLUS - Irrecoverable carbon dataset"), tr("URL not defined")
diff --git a/src/cplus_plugin/ui/carbon_settings.ui b/src/cplus_plugin/ui/carbon_settings.ui
index 49560342..fb451644 100644
--- a/src/cplus_plugin/ui/carbon_settings.ui
+++ b/src/cplus_plugin/ui/carbon_settings.ui
@@ -120,44 +120,31 @@
5
- -
-
+
-
+
+
+ Initiate new download or refesh previous download in the background
+
- URL
+ Start download
-
-
+
- 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
+ Specify the local path for saving the downloaded file
- -
+
-
- Save as
-
-
-
- -
-
-
- -
-
-
- Initiate new download or refesh previous download in the background
-
-
- Start download
+ Save file as
- -
+
-
4