Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 98 additions & 13 deletions src/cplus_plugin/api/layer_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
QgsFileDownloader,
QgsCoordinateTransform,
QgsCoordinateReferenceSystem,
QgsVectorLayer,
)
from qgis.PyQt import QtCore

Expand All @@ -38,8 +39,15 @@
compress_raster,
get_layer_type,
convert_size,
transform_extent,
)
from .request import (
CplusApiPooling,
CplusApiRequest,
CplusApiRequestError,
CplusApiUrl,
JOB_COMPLETED_STATUS,
)
from .request import CplusApiPooling, CplusApiRequest, CplusApiUrl, JOB_COMPLETED_STATUS
from ..definitions.defaults import DEFAULT_CRS_ID


Expand Down Expand Up @@ -744,7 +752,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)

Expand All @@ -770,23 +777,76 @@ 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
completed.

Use `poll_once` method for non-blocking iterations.

:returns: True if the calculation process succeeded or
False it if failed.
:rtype: 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
Expand Down Expand Up @@ -827,12 +887,32 @@ 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:
# Use poll once which is non-blocking
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:
Expand All @@ -845,18 +925,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}",
Expand All @@ -865,7 +947,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)
Expand All @@ -875,7 +957,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")
Expand Down
31 changes: 31 additions & 0 deletions src/cplus_plugin/api/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions src/cplus_plugin/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
4 changes: 4 additions & 0 deletions src/cplus_plugin/definitions/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 0 additions & 1 deletion src/cplus_plugin/gui/ncs_pathway_editor_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion src/cplus_plugin/gui/qgis_cplus_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, "")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand Down
Loading
Loading