Skip to content

Commit d637256

Browse files
authored
Merge pull request #773 from gkahiu/carbon_calc_enhancements
Carbon Calculation Enhancements
2 parents 036b55f + a4031c0 commit d637256

File tree

9 files changed

+449
-87
lines changed

9 files changed

+449
-87
lines changed

src/cplus_plugin/api/layer_tasks.py

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
QgsFileDownloader,
2121
QgsCoordinateTransform,
2222
QgsCoordinateReferenceSystem,
23+
QgsVectorLayer,
2324
)
2425
from qgis.PyQt import QtCore
2526

@@ -38,8 +39,15 @@
3839
compress_raster,
3940
get_layer_type,
4041
convert_size,
42+
transform_extent,
43+
)
44+
from .request import (
45+
CplusApiPooling,
46+
CplusApiRequest,
47+
CplusApiRequestError,
48+
CplusApiUrl,
49+
JOB_COMPLETED_STATUS,
4150
)
42-
from .request import CplusApiPooling, CplusApiRequest, CplusApiUrl, JOB_COMPLETED_STATUS
4351
from ..definitions.defaults import DEFAULT_CRS_ID
4452

4553

@@ -744,7 +752,6 @@ class CalculateNatureBaseZonalStatsTask(QgsTask):
744752
"""
745753

746754
status_message_changed = QtCore.pyqtSignal(str)
747-
progress_changed = QtCore.pyqtSignal(float)
748755
results_ready = QtCore.pyqtSignal(object)
749756
task_finished = QtCore.pyqtSignal(bool)
750757

@@ -770,23 +777,76 @@ def _get_bbox_for_request(self) -> str:
770777

771778
return str(self.bbox)
772779

773-
# Otherwise get saved scenario extent
774-
extent = settings_manager.get_value(Settings.SCENARIO_EXTENT, default=None)
780+
# Otherwise get saved extents from settings
781+
clip_to_studyarea = settings_manager.get_value(
782+
Settings.CLIP_TO_STUDYAREA, default=False, setting_type=bool
783+
)
784+
if clip_to_studyarea:
785+
# From vector layer
786+
study_area_path = settings_manager.get_value(
787+
Settings.STUDYAREA_PATH, default="", setting_type=str
788+
)
789+
if not study_area_path or not os.path.exists(study_area_path):
790+
log("Path for determining layer extent is invalid.", info=False)
791+
return ""
792+
793+
aoi_layer = QgsVectorLayer(study_area_path, "AOI Layer")
794+
if not aoi_layer.isValid():
795+
log("AOI layer is invalid.", info=False)
796+
return ""
797+
798+
source_crs = aoi_layer.crs()
799+
if not source_crs:
800+
log("CRS of AOI layer is undefined.", info=False)
801+
return ""
802+
803+
aoi_extent = aoi_layer.extent()
804+
if not aoi_extent:
805+
log("Extent of AOI layer is undefined.", info=False)
806+
return ""
807+
808+
# Reproject extent if required
809+
destination_crs = QgsCoordinateReferenceSystem("EPSG:4326")
810+
if source_crs != destination_crs:
811+
aoi_extent = transform_extent(aoi_extent, source_crs, destination_crs)
812+
813+
extent = [
814+
aoi_extent.xMinimum(),
815+
aoi_extent.yMinimum(),
816+
aoi_extent.xMaximum(),
817+
aoi_extent.yMaximum(),
818+
]
819+
else:
820+
# From explicit extent definition
821+
settings_extent = settings_manager.get_value(
822+
Settings.SCENARIO_EXTENT, default=None
823+
)
824+
# Ensure in minX, minY, maxX, maxY format
825+
extent = [
826+
float(settings_extent[0]),
827+
float(settings_extent[2]),
828+
float(settings_extent[1]),
829+
float(settings_extent[3]),
830+
]
831+
775832
if not extent or len(extent) < 4:
776833
raise ValueError("Scenario extent is not defined or invalid.")
777834

778-
return f"{float(extent[0])},{float(extent[2])},{float(extent[1])},{float(extent[3])}"
835+
return f"{extent[0]},{extent[1]},{extent[2]},{extent[3]}"
779836

780837
def run(self) -> bool:
781838
"""Initiate the zonal statistics calculation and poll until
782839
completed.
783840
841+
Use `poll_once` method for non-blocking iterations.
842+
784843
:returns: True if the calculation process succeeded or
785844
False it if failed.
786845
:rtype: bool
787846
"""
788847
try:
789848
bbox_str = self._get_bbox_for_request()
849+
log(f"BBOX for Zonal stats request: {bbox_str}")
790850
except Exception as e:
791851
log(f"Invalid bbox: {e}")
792852
return False
@@ -827,12 +887,32 @@ def run(self) -> bool:
827887
f"Zonal statistics calculation started, task id: {task_uuid}"
828888
)
829889

830-
pooling = self.request.fetch_zonal_statistics_progress(task_uuid)
890+
polling = self.request.fetch_zonal_statistics_progress(task_uuid)
831891

832892
# Repeatedly poll until final status
893+
status = True
833894
try:
834-
while not self.isCanceled():
835-
response = pooling.results() or {}
895+
while True:
896+
if self.isCanceled():
897+
polling.cancelled = True
898+
status = False
899+
break
900+
901+
try:
902+
# Use poll once which is non-blocking
903+
response = polling.poll_once() or {}
904+
except CplusApiRequestError as ex:
905+
log(f"Polling error: {ex}", info=False)
906+
status = False
907+
break
908+
except Exception as ex:
909+
log(
910+
f"Error while polling zonal statistics progress: {ex}",
911+
info=False,
912+
)
913+
time.sleep(self.polling_interval)
914+
continue
915+
836916
status_str = response.get("status")
837917
progress = response.get("progress", 0.0)
838918
try:
@@ -845,18 +925,20 @@ def run(self) -> bool:
845925
progress_value = 0.0
846926

847927
self.setProgress(int(progress_value))
848-
self.progress_changed.emit(progress_value)
849928
self.set_status_message(
850929
f"Zonal statistics: {status_str} ({progress_value:.1f}%)"
851930
)
931+
852932
if status_str in CplusApiPooling.FINAL_STATUS_LIST:
933+
if status_str != JOB_COMPLETED_STATUS:
934+
status = False
853935
self.result = response
854936
break
855937

856-
# Pooling.results already sleeps between attempts, but keep guard
938+
# Sleep between poll iterations so that we can control the frequency
857939
time.sleep(self.polling_interval)
858940

859-
return True
941+
return status
860942
except Exception as ex:
861943
log(
862944
f"Error while polling zonal statistics progress: {ex}",
@@ -865,7 +947,7 @@ def run(self) -> bool:
865947
return False
866948

867949
def finished(self, result: bool):
868-
"""Emit signals and optionally persist results in settings."""
950+
"""Emit signals and persist results in settings."""
869951
if result and self.result:
870952
results = self.result.get("results", [])
871953
self.results_ready.emit(results)
@@ -875,7 +957,10 @@ def finished(self, result: bool):
875957
"Empty result set for zonal statistics calculation of Naturebase layers.",
876958
info=False,
877959
)
878-
result_info = ResultInfo(results, self.result.get("finished_at", ""))
960+
sorted_results = []
961+
if results:
962+
sorted_results = sorted(results, key=lambda d: d["layer_name"])
963+
result_info = ResultInfo(sorted_results, self.result.get("finished_at", ""))
879964
settings_manager.save_nature_base_zonal_stats(result_info)
880965
else:
881966
self.set_status_message("Zonal statistics task failed or was cancelled")

src/cplus_plugin/api/request.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,37 @@ def __call_api(self) -> typing.Tuple[dict, int]:
129129
return self.context.get(self.url)
130130
return self.context.post(self.url, self.data)
131131

132+
def poll_once(self) -> dict:
133+
"""Perform a single API call to the network resource
134+
and returns the response dict.
135+
136+
This does not sleep or recurse. It increments the
137+
retry counter and enforces cancellation / timeout rules.
138+
Use this from external loops to control the
139+
loop frequency and update dependencies
140+
after each response.
141+
142+
:returns: Dictionary containing the response details.
143+
:rtype: dict
144+
"""
145+
if self.cancelled:
146+
return {"status": JOB_CANCELLED_STATUS}
147+
148+
if self.limit != -1 and self.current_repeat >= self.limit:
149+
raise CplusApiRequestError("Request Timeout when fetching status!")
150+
151+
self.current_repeat += 1
152+
153+
response, status_code = self.__call_api()
154+
if status_code != 200:
155+
error_detail = response.get("detail", "Unknown Error!")
156+
raise CplusApiRequestError(f"{status_code} - {error_detail}")
157+
158+
if self.on_response_fetched:
159+
self.on_response_fetched(response)
160+
161+
return response
162+
132163
def results(self) -> dict:
133164
"""Fetch the results from API every X seconds and stop when status is in the final status list.
134165

src/cplus_plugin/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ class Settings(enum.Enum):
275275

276276
# Naturebase mean zonal statistics
277277
NATURE_BASE_MEAN_ZONAL_STATS = "nature_base_zonal_stats/mean"
278+
AUTO_REFRESH_NATURE_BASE_ZONAL_STATS = "nature_base_zonal_stats/auto_refresh"
278279

279280
# Constant Rasters Dialog
280281
CONSTANT_RASTERS_DIALOG_ACTIVITY_TYPE = (

src/cplus_plugin/definitions/defaults.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
RESTORE_CARBON_IMPACT_HEADER = "C.I. (Restore)"
5151
TOTAL_CARBON_IMPACT_HEADER = "Total C.I."
5252

53+
# Naturebase carbon impact table headers
54+
LAYER_NAME_HEADER = "Layer Name"
55+
CARBON_IMPACT_PER_HA_HEADER = "tCO2e/ha"
56+
5357
ICON_PATH = ":/plugins/cplus_plugin/icon.svg"
5458
REPORT_SETTINGS_ICON_PATH = str(
5559
os.path.normpath(

src/cplus_plugin/gui/ncs_pathway_editor_dialog.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ def __init__(self, parent=None, ncs_pathway=None, excluded_names=None):
110110
# Naturebase carbon impact reference
111111
carbon_impact_info = settings_manager.get_nature_base_zonal_stats()
112112
if carbon_impact_info:
113-
# Manage page
114113
self.cbo_naturebase_carbon_mng.addItem("")
115114
self.cbo_naturebase_carbon_rst.addItem("")
116115
for impact in carbon_impact_info.result_collection:

src/cplus_plugin/gui/qgis_cplus_main.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
from .progress_dialog import OnlineProgressDialog, ReportProgressDialog, ProgressDialog
5858
from ..trends_earth import auth
5959
from ..api.scenario_task_api_client import ScenarioAnalysisTaskApiClient
60-
from ..api.layer_tasks import FetchDefaultLayerTask
60+
from ..api.layer_tasks import calculate_zonal_stats_task, FetchDefaultLayerTask
6161
from ..api.scenario_history_tasks import (
6262
FetchScenarioHistoryTask,
6363
FetchScenarioOutputTask,
@@ -404,6 +404,9 @@ def prepare_input(self):
404404
self.scenario_name.textChanged.connect(self.save_scenario)
405405
self.scenario_description.textChanged.connect(self.save_scenario)
406406
self.extent_box.extentChanged.connect(self.save_scenario)
407+
self.extent_box.extentChanged.connect(
408+
lambda s: self.update_naturebase_carbon_impact()
409+
)
407410

408411
icon_pixmap = QtGui.QPixmap(ICON_PATH)
409412
self.icon_la.setPixmap(icon_pixmap)
@@ -553,6 +556,9 @@ def on_aoi_source_changed(self, button_id: int, toggled: bool):
553556

554557
self.save_scenario()
555558

559+
# Check and fetch carbon impact for the current extent
560+
self.update_naturebase_carbon_impact()
561+
556562
def _on_studyarea_file_changed(self):
557563
"""Slot raised to when the area of interest is selected from a local file system."""
558564
data_dir = settings_manager.get_value(Settings.LAST_DATA_DIR, "")
@@ -580,6 +586,9 @@ def _on_studyarea_file_changed(self):
580586

581587
self.save_scenario()
582588

589+
# Check and fetch carbon impact for the current extent
590+
self.update_naturebase_carbon_impact()
591+
583592
def _on_studyarea_layer_changed(self, layer):
584593
"""Slot raised to when the area of interest is selected from a map layers."""
585594
if layer is not None:
@@ -590,6 +599,9 @@ def _on_studyarea_layer_changed(self, layer):
590599

591600
self.save_scenario()
592601

602+
# Check and fetch carbon impact for the current extent
603+
self.update_naturebase_carbon_impact()
604+
593605
def can_clip_to_studyarea(self) -> bool:
594606
"""Return true if clipping layers by study area is selected"""
595607
clip_to_studyarea = False
@@ -601,6 +613,18 @@ def can_clip_to_studyarea(self) -> bool:
601613
clip_to_studyarea = True
602614
return clip_to_studyarea
603615

616+
def update_naturebase_carbon_impact(self):
617+
"""Fetch the naturebase zonal stats based on the current extent."""
618+
auto_refresh = settings_manager.get_value(
619+
Settings.AUTO_REFRESH_NATURE_BASE_ZONAL_STATS,
620+
default=False,
621+
setting_type=bool,
622+
)
623+
if not auto_refresh:
624+
return
625+
626+
_ = calculate_zonal_stats_task()
627+
604628
def get_studyarea_path(self) -> str:
605629
"""Return the path of the study area
606630

0 commit comments

Comments
 (0)