From 217bcaa1846d5ee96ce4668fe4f979802065c90c Mon Sep 17 00:00:00 2001 From: Tim Sutton Date: Thu, 10 Oct 2024 23:10:49 +0100 Subject: [PATCH 1/2] WIP in progress point and paths processor and widget. Have analsysis as top level node in tree --- geest/core/algorithms/__init__py | 0 geest/core/algorithms/area_iterator.py | 129 +++++ .../algorithms/point_and_paths_processor.py | 301 +++++++++++ geest/gui/views/treeview.py | 492 ++++++++++++------ .../gui/widgets/point_and_polyline_widget.py | 246 +++++++++ geest/resources/model.json | 1 + 6 files changed, 1024 insertions(+), 145 deletions(-) create mode 100644 geest/core/algorithms/__init__py create mode 100644 geest/core/algorithms/area_iterator.py create mode 100644 geest/core/algorithms/point_and_paths_processor.py create mode 100644 geest/gui/widgets/point_and_polyline_widget.py diff --git a/geest/core/algorithms/__init__py b/geest/core/algorithms/__init__py new file mode 100644 index 0000000..e69de29 diff --git a/geest/core/algorithms/area_iterator.py b/geest/core/algorithms/area_iterator.py new file mode 100644 index 0000000..95860c7 --- /dev/null +++ b/geest/core/algorithms/area_iterator.py @@ -0,0 +1,129 @@ +from qgis.core import ( + QgsFeatureRequest, + QgsMessageLog, + QgsVectorLayer, + QgsProject, + QgsGeometry, + Qgis, +) +from typing import Iterator, Tuple + + +class AreaIterator: + """ + An iterator to yield pairs of geometries from polygon and bbox layers + found in a GeoPackage file, along with a progress percentage. + + Attributes: + gpkg_path (str): The path to the GeoPackage file. + + Precondition: + study_area_polygons (QgsVectorLayer): The vector layer containing polygons. + study_area_bboxes (QgsVectorLayer): The vector layer containing bounding boxes. + + There should be a one-to-one correspondence between the polygons and bounding boxes. + + Example usage: + To use the iterator, simply pass the path to the GeoPackage: + + ```python + gpkg_path = '/path/to/your/geopackage.gpkg' + area_iterator = AreaIterator(gpkg_path) + + for polygon_geometry, bbox_geometry, progress_percent in area_iterator: + QgsMessageLog.logMessage(f"Polygon Geometry: {polygon_geometry.asWkt()}", 'Geest') + QgsMessageLog.logMessage(f"BBox Geometry: {bbox_geometry.asWkt()}", 'Geest') + QgsMessageLog.logMessage(f"Progress: {progress_percent:.2f}%", 'Geest') + ``` + """ + + def __init__(self, gpkg_path: str) -> None: + """ + Initialize the AreaIterator with the path to the GeoPackage. + + Args: + gpkg_path (str): The file path to the GeoPackage. + """ + self.gpkg_path = gpkg_path + + # Load the polygon and bbox layers from the GeoPackage + self.polygon_layer: QgsVectorLayer = QgsVectorLayer( + f"{gpkg_path}|layername=study_area_polygons", "study_area_polygons", "ogr" + ) + self.bbox_layer: QgsVectorLayer = QgsVectorLayer( + f"{gpkg_path}|layername=study_area_bboxes", "study_area_bboxes", "ogr" + ) + + # Verify that both layers were loaded correctly + if not self.polygon_layer.isValid(): + QgsMessageLog.logMessage( + "Error: 'study_area_polygons' layer failed to load from the GeoPackage", + "Geest", + level=Qgis.Critical, + ) + raise ValueError( + "Failed to load 'study_area_polygons' layer from the GeoPackage." + ) + + if not self.bbox_layer.isValid(): + QgsMessageLog.logMessage( + "Error: 'study_area_bboxes' layer failed to load from the GeoPackage", + "Geest", + level=Qgis.Critical, + ) + raise ValueError( + "Failed to load 'study_area_bboxes' layer from the GeoPackage." + ) + + # Get the total number of polygon features for progress calculation + self.total_features: int = self.polygon_layer.featureCount() + + def __iter__(self) -> Iterator[Tuple[QgsGeometry, QgsGeometry, float]]: + """ + Iterator that yields pairs of geometries from the polygon layer and the corresponding bbox layer, + along with a progress percentage. + + Yields: + Iterator[Tuple[QgsGeometry, QgsGeometry, float]]: Yields a tuple of polygon and bbox geometries, + along with a progress value representing the percentage of the iteration completed. + """ + try: + # Ensure both layers have the same CRS + if self.polygon_layer.crs() != self.bbox_layer.crs(): + QgsMessageLog.logMessage( + "Warning: CRS mismatch between polygon and bbox layers", + "Geest", + level=Qgis.Warning, + ) + return + + # Iterate over each polygon feature and calculate progress + for index, polygon_feature in enumerate(self.polygon_layer.getFeatures()): + polygon_id: int = polygon_feature.id() + + # Request the corresponding bbox feature based on the polygon's ID + bbox_request: QgsFeatureRequest = QgsFeatureRequest().setFilterFid( + polygon_id + ) + bbox_feature = next(self.bbox_layer.getFeatures(bbox_request), None) + + if bbox_feature: + # Calculate the progress as the percentage of features processed + progress_percent: float = ((index + 1) / self.total_features) * 100 + + # Yield a tuple with polygon geometry, bbox geometry, and progress percentage + yield polygon_feature.geometry(), bbox_feature.geometry(), progress_percent + + else: + QgsMessageLog.logMessage( + f"Warning: No matching bbox found for polygon ID {polygon_id}", + "Geest", + level=Qgis.Warning, + ) + + except Exception as e: + QgsMessageLog.logMessage( + f"Critical: Error during iteration - {str(e)}", + "Geest", + level=Qgis.Critical, + ) diff --git a/geest/core/algorithms/point_and_paths_processor.py b/geest/core/algorithms/point_and_paths_processor.py new file mode 100644 index 0000000..5e158ef --- /dev/null +++ b/geest/core/algorithms/point_and_paths_processor.py @@ -0,0 +1,301 @@ +from qgis.core import ( + QgsVectorLayer, + QgsProcessingFeedback, + QgsProcessing, + QgsProcessingAlgorithm, + QgsProject, + QgsRasterLayer, + QgsProcessingException, + QgsFeature, + QgsGeometry, + QgsVectorFileWriter, + edit, +) +import processing # QGIS processing toolbox +from .area_iterator import AreaIterator +from typing import List, Tuple +import os + + +class PointAndPathsProcessor: + """ + A class to process spatial areas and perform spatial analysis using QGIS processing algorithms. + + This class iterates over areas (polygons) and corresponding bounding boxes within a GeoPackage. + For each area, it performs spatial operations on input layers representing a linear network (such as roads or paths), + pedestrian crossings, and a grid layer. The results are processed and rasterized. + + The following steps are performed for each area: + + 1. Select lines (from a network layer) that intersect with the current area. + 2. Select points (from a crossings layer) that intersect with the current area. + 3. Select grid cells (from a grid layer) that intersect with the lines or points, ensuring no duplicates. + 4. Assign values to the grid cells based on the number of intersecting features: + - A value of 3 if the grid cell intersects only one feature. + - A value of 5 if the grid cell intersects more than one feature. + 5. Rasterize the grid cells, using their assigned values to create a raster for each area. + 6. Convert the resulting raster to byte format to minimize space usage. + 7. After processing all areas, combine the resulting byte rasters into a single VRT file. + + Inputs: + network_layer (QgsVectorLayer): A linear network layer, such as roads or paths. + crossings_layer (QgsVectorLayer): A point layer representing pedestrian crossings. + grid_layer (QgsVectorLayer): A polygon layer containing grid cells of a specific dimension (e.g., 100m x 100m). + working_dir (str): The working directory where intermediate outputs will be stored. + + Example: + ```python + processor = PointAndPathsProcessor(network_layer, crossings_layer, grid_layer, '/path/to/working_dir') + processor.process_areas('/path/to/your/geopackage.gpkg') + ``` + """ + + def __init__( + self, + network_layer: QgsVectorLayer, + crossings_layer: QgsVectorLayer, + grid_layer: QgsVectorLayer, + working_dir: str, + ) -> None: + """ + Initialize the PointAndPathsProcessor with input layers and working directory. + + Args: + network_layer (QgsVectorLayer): The input linear network layer (e.g., roads or paths). + crossings_layer (QgsVectorLayer): The input point layer representing pedestrian crossings. + grid_layer (QgsVectorLayer): The input grid layer containing polygon cells. + working_dir (str): Directory where temporary and output files will be stored. + """ + self.network_layer = network_layer + self.crossings_layer = crossings_layer + self.grid_layer = grid_layer + self.working_dir = working_dir + + def process_areas(self, gpkg_path: str) -> None: + """ + Main function to iterate over areas from the GeoPackage and perform the analysis for each area. + + This function processes areas (defined by polygons and bounding boxes) from a GeoPackage using + the provided input layers (network, crossings, grid). It applies the steps of selecting intersecting + features, assigning values to grid cells, rasterizing the grid, converting to byte format, and finally + combining the rasters into a VRT. + + Args: + gpkg_path (str): Path to the GeoPackage file containing the study areas and bounding boxes. + """ + feedback = QgsProcessingFeedback() + area_iterator = AreaIterator(gpkg_path) + + # Iterate over areas and perform the analysis for each + for index, (current_area, current_bbox, progress) in enumerate(area_iterator): + feedback.pushInfo( + f"Processing area {index+1} with progress {progress:.2f}%" + ) + + # Step 2: Select lines that intersect with the current area and store in a temporary layer + area_lines = self._select_features( + self.network_layer, current_area, "area_lines" + ) + + # Step 3: Select points that intersect with the current area and store in a temporary layer + area_points = self._select_features( + self.crossings_layer, current_area, "area_points" + ) + + # Step 4: Select grid cells that intersect with roads or points + area_grid = self._select_grid_cells( + self.grid_layer, area_lines, area_points + ) + + # Step 5: Assign values to grid cells + area_grid = self._assign_values_to_grid(area_grid) + + # Step 6: Rasterize the grid layer using the assigned values + raster_output = self._rasterize_grid(area_grid, current_bbox, index) + + # Step 7: Convert the raster to byte format + byte_raster = self._convert_to_byte_raster(raster_output, index) + + # Step 8: Combine the resulting byte rasters into a single VRT + self._combine_rasters_to_vrt(index + 1) + + def _select_features( + self, layer: QgsVectorLayer, area_geom: QgsGeometry, output_name: str + ) -> QgsVectorLayer: + """ + Select features from the input layer that intersect with the given area geometry. + + Args: + layer (QgsVectorLayer): The input layer (e.g., roads or crossings) to select features from. + area_geom (QgsGeometry): The current area geometry for which intersections are evaluated. + output_name (str): A name for the output temporary layer to store selected features. + + Returns: + QgsVectorLayer: A new temporary layer containing features that intersect with the given area geometry. + """ + output_path = os.path.join(self.working_dir, f"{output_name}.shp") + params = { + "INPUT": layer, + "PREDICATE": [0], # Intersects predicate + "GEOMETRY": area_geom, + "OUTPUT": output_path, + } + result = processing.run("native:extractbyextent", params) + return QgsVectorLayer(result["OUTPUT"], output_name, "ogr") + + def _select_grid_cells( + self, + grid_layer: QgsVectorLayer, + lines_layer: QgsVectorLayer, + points_layer: QgsVectorLayer, + ) -> QgsVectorLayer: + """ + Select grid cells that intersect with either lines or points by iterating over features and adding them to a set. + + Args: + grid_layer (QgsVectorLayer): The input grid layer containing polygon cells. + lines_layer (QgsVectorLayer): The input layer containing lines (e.g., roads/paths). + points_layer (QgsVectorLayer): The input layer containing points (e.g., pedestrian crossings). + + Returns: + QgsVectorLayer: A temporary layer containing grid cells that intersect with either lines or points. + """ + output_path = os.path.join(self.working_dir, "area_grid_selected.shp") + intersected_grid_ids = set() # Set to track unique intersecting grid cell IDs + + # Iterate over lines and find intersecting grid cells + for line_feature in lines_layer.getFeatures(): + for grid_feature in grid_layer.getFeatures(): + if grid_feature.geometry().intersects(line_feature.geometry()): + intersected_grid_ids.add(grid_feature.id()) + + # Iterate over points and find intersecting grid cells + for point_feature in points_layer.getFeatures(): + for grid_feature in grid_layer.getFeatures(): + if grid_feature.geometry().intersects(point_feature.geometry()): + intersected_grid_ids.add(grid_feature.id()) + + # Collect the selected grid cells into a new temporary layer + selected_grid_features = [ + f for f in grid_layer.getFeatures() if f.id() in intersected_grid_ids + ] + selected_grid_layer = self._create_temp_layer( + selected_grid_features, output_path + ) + + return selected_grid_layer + + def _create_temp_layer( + self, features: List[QgsFeature], output_path: str + ) -> QgsVectorLayer: + """ + Create a temporary vector layer with the provided features. + + Args: + features (List[QgsFeature]): A list of selected QgsFeatures to add to the temporary layer. + output_path (str): The file path for storing the temporary output layer. + + Returns: + QgsVectorLayer: A new temporary vector layer containing the selected features. + """ + crs = features[0].geometry().crs() if features else None + temp_layer = QgsVectorLayer( + "Polygon?crs={}".format(crs.authid()), "temporary_layer", "memory" + ) + temp_layer_data = temp_layer.dataProvider() + + # Add fields and features to the new layer + temp_layer_data.addAttributes([f.fieldName() for f in features[0].fields()]) + temp_layer.updateFields() + + temp_layer_data.addFeatures(features) + + # Save the memory layer to a file for persistence + QgsVectorFileWriter.writeAsVectorFormat( + temp_layer, output_path, "UTF-8", temp_layer.crs(), "ESRI Shapefile" + ) + + return QgsVectorLayer(output_path, os.path.basename(output_path), "ogr") + + def _assign_values_to_grid(self, grid_layer: QgsVectorLayer) -> QgsVectorLayer: + """ + Assign values to grid cells based on the number of intersecting features. + + A value of 3 is assigned to cells that intersect with one feature, and a value of 5 is assigned to + cells that intersect with more than one feature. + + Args: + grid_layer (QgsVectorLayer): The input grid layer containing polygon cells. + + Returns: + QgsVectorLayer: The grid layer with values assigned to the 'value' field. + """ + with edit(grid_layer): + for feature in grid_layer.getFeatures(): + intersecting_features = feature["intersecting_features"] + if intersecting_features == 1: + feature["value"] = 3 + elif intersecting_features > 1: + feature["value"] = 5 + grid_layer.updateFeature(feature) + return grid_layer + + def _rasterize_grid( + self, grid_layer: QgsVectorLayer, bbox: QgsGeometry, index: int + ) -> str: + """ + Rasterize the grid layer based on the 'value' attribute. + + Args: + grid_layer (QgsVectorLayer): The grid layer to rasterize. + bbox (QgsGeometry): The bounding box for the raster extents. + index (int): The current index used for naming the output raster. + + Returns: + str: The file path to the rasterized output. + """ + output_path = os.path.join(self.working_dir, f"raster_output_{index}.tif") + params = { + "INPUT": grid_layer, + "FIELD": "value", + "EXTENT": bbox.boundingBox(), + "OUTPUT": output_path, + } + processing.run("gdal:rasterize", params) + return output_path + + def _convert_to_byte_raster(self, raster_path: str, index: int) -> str: + """ + Convert the raster to byte format to reduce the file size. + + Args: + raster_path (str): The path to the input raster to be converted. + index (int): The current index for naming the output byte raster. + + Returns: + str: The file path to the byte raster output. + """ + byte_raster_path = os.path.join(self.working_dir, f"byte_raster_{index}.tif") + params = { + "INPUT": raster_path, + "BAND": 1, + "OUTPUT": byte_raster_path, + "TYPE": 1, # Byte format + } + processing.run("gdal:translate", params) + return byte_raster_path + + def _combine_rasters_to_vrt(self, num_rasters: int) -> None: + """ + Combine all the byte rasters into a single VRT file. + + Args: + num_rasters (int): The number of rasters to combine into a VRT. + """ + raster_paths = [ + os.path.join(self.working_dir, f"byte_raster_{i}.tif") + for i in range(num_rasters) + ] + vrt_path = os.path.join(self.working_dir, "combined_rasters.vrt") + params = {"INPUT": raster_paths, "OUTPUT": vrt_path} + processing.run("gdal:buildvrt", params) diff --git a/geest/gui/views/treeview.py b/geest/gui/views/treeview.py index c6928df..b28af8c 100644 --- a/geest/gui/views/treeview.py +++ b/geest/gui/views/treeview.py @@ -23,157 +23,233 @@ from geest.core import JsonTreeItem +from qgis.PyQt.QtWidgets import QAbstractItemDelegate, QTreeView, QMessageBox +from qgis.PyQt.QtCore import QAbstractItemModel, QModelIndex, Qt +from PyQt5.QtGui import QColor +from geest.core import JsonTreeItem + + class JsonTreeModel(QAbstractItemModel): - """Custom QAbstractItemModel to manage JSON data.""" + """ + A custom tree model for managing hierarchical JSON data in a QTreeView, including an "Analysis" root item + under which Dimensions, Factors, and Indicators are stored. Each tree item has attributes that store custom + properties such as analysis name, description, and working folder. + + The model allows editing of certain fields (e.g., weighting) and supports serialization back to JSON. + + Attributes: + rootItem (JsonTreeItem): The root item of the tree model, which holds the "Analysis" item as a child. + original_value (str or float): Stores the original value of an item before it is edited. + """ def __init__(self, json_data, parent=None): + """ + Initializes the JsonTreeModel with a given JSON structure and sets up the tree hierarchy. + + Args: + json_data (dict): The input JSON structure containing the analysis, dimensions, factors, and indicators. + parent (QObject): Optional parent object for the model. + """ super().__init__(parent) self.rootItem = JsonTreeItem(["GEEST2", "Status", "Weight"], "root") - self.loadJsonData(json_data) self.original_value = None # To store the original value before editing + self.loadJsonData(json_data) def loadJsonData(self, json_data): - """Load JSON data into the model, showing dimensions, factors, layers, and weightings.""" + """ + Loads the JSON data into the tree model, creating a hierarchical structure with the "Analysis" node + as the parent. Dimensions, Factors, and Indicators are nested accordingly. + + The "Analysis" node contains custom attributes for the analysis name, description, and working folder. + + Args: + json_data (dict): The JSON data representing the analysis and its hierarchical structure. + """ self.beginResetModel() self.rootItem = JsonTreeItem(["GEEST2", "Status", "Weight"], "root") - # Process dimensions, factors, and layers + # Create the 'Analysis' parent item + analysis_name = json_data.get("analysis_name", "Analysis") + analysis_description = json_data.get("description", "No Description") + working_folder = json_data.get("working_folder", "Not Set") + + # Store special properties in the data(3) dictionary + analysis_attributes = { + "Analysis Name": analysis_name, + "Description": analysis_description, + "Working Folder": working_folder, + } + + # Create the "Analysis" item + analysis_item = JsonTreeItem( + [analysis_name, "", "", analysis_attributes], "analysis", self.rootItem + ) + self.rootItem.appendChild(analysis_item) + + # Process dimensions, factors, and layers under the 'Analysis' parent item for dimension in json_data.get("dimensions", []): - dimension_name = dimension["name"].title() # Show dimensions in title case - dimension_attributes = {} - dimension_attributes["id"] = dimension.get("id", "") - dimension_attributes["name"] = dimension.get("name", "") - dimension_attributes["text"] = dimension.get("text", "") - dimension_attributes["required"] = dimension.get("required", False) - dimension_attributes["default_analysis_weighting"] = dimension.get( - "default_analysis_weighting", 0.0 - ) - dimension_attributes["Analysis Mode"] = dimension.get( - "Factor Aggregation", "" - ) - dimension_attributes["Result"] = dimension.get("Result", "") - dimension_attributes["Execution Start Time"] = dimension.get( - "Execution Start Time", "" - ) - dimension_attributes["Dimension Result File"] = dimension.get( - "Dimension Result File", "" - ) - dimension_attributes["Execution End Time"] = dimension.get( - "Execution End Time", "" - ) - status = "🔴" - result = dimension.get("Result", "") - if "Workflow Completed" in result: - status = "✔️" - dimension_item = JsonTreeItem( - [dimension_name, status, "", dimension_attributes], - "dimension", - self.rootItem, # parent - ) - self.rootItem.appendChild(dimension_item) + dimension_item = self._create_dimension_item(dimension, analysis_item) + # Process factors under each dimension for factor in dimension.get("factors", []): - factor_attributes = {} - factor_attributes["id"] = factor.get("id", "") - factor_attributes["name"] = factor.get("name", "") - factor_attributes["text"] = factor.get("text", "") - factor_attributes["required"] = factor.get("required", False) - factor_attributes["default_dimension_weighting"] = factor.get( - "default_analysis_weighting", 0.0 - ) - factor_attributes["Analysis Mode"] = factor.get( - "Factor Aggregation", "" - ) - factor_attributes["Result"] = factor.get("Result", "") - factor_attributes["Execution Start Time"] = factor.get( - "Execution Start Time", "" - ) - factor_attributes["Factor Result File"] = factor.get( - "Factor Result File", "" - ) - factor_attributes["Execution End Time"] = factor.get( - "Execution End Time", "" - ) - status = "🔴" - result = factor_attributes.get("Result", "") - if "Workflow Completed" in result: - status = "✔️" - factor_item = JsonTreeItem( - [factor["name"], status, "", factor_attributes], - "factor", - dimension_item, # parent - ) - dimension_item.appendChild(factor_item) - - factor_weighting_sum = 0.0 + factor_item = self._create_factor_item(factor, dimension_item) + # Process indicators (layers) under each factor for indicator in factor.get("layers", []): + self._create_indicator_item(indicator, factor_item) - status = "🔴" - result = indicator.get("Indicator Result", "") - if "Workflow Completed" in result: - status = "✔️" - indicator_item = JsonTreeItem( - [ - indicator["Layer"], - status, - indicator.get("Factor Weighting", 0), - indicator, - ], - "layer", - factor_item, - ) + self.endResetModel() - factor_item.appendChild(indicator_item) + def _create_dimension_item(self, dimension, parent_item): + """ + Creates a new Dimension item under the specified parent item (Analysis) and populates it with custom attributes. + + Args: + dimension (dict): The dimension data to be added to the tree. + parent_item (JsonTreeItem): The parent item (Analysis) under which the dimension is added. + + Returns: + JsonTreeItem: The created dimension item. + """ + dimension_name = dimension["name"].title() # Title case for dimensions + dimension_attributes = { + "id": dimension.get("id", ""), + "name": dimension.get("name", ""), + "text": dimension.get("text", ""), + "required": dimension.get("required", False), + "default_analysis_weighting": dimension.get( + "default_analysis_weighting", 0.0 + ), + "Analysis Mode": dimension.get("Factor Aggregation", ""), + "Result": dimension.get("Result", ""), + "Execution Start Time": dimension.get("Execution Start Time", ""), + "Dimension Result File": dimension.get("Dimension Result File", ""), + "Execution End Time": dimension.get("Execution End Time", ""), + } + status = ( + "🔴" if "Workflow Completed" not in dimension_attributes["Result"] else "✔️" + ) + + dimension_item = JsonTreeItem( + [dimension_name, status, "", dimension_attributes], "dimension", parent_item + ) + parent_item.appendChild(dimension_item) + + return dimension_item + + def _create_factor_item(self, factor, parent_item): + """ + Creates a new Factor item under the specified Dimension item and populates it with custom attributes. + + Args: + factor (dict): The factor data to be added to the tree. + parent_item (JsonTreeItem): The parent item (Dimension) under which the factor is added. + + Returns: + JsonTreeItem: The created factor item. + """ + factor_attributes = { + "id": factor.get("id", ""), + "name": factor.get("name", ""), + "text": factor.get("text", ""), + "required": factor.get("required", False), + "default_dimension_weighting": factor.get( + "default_analysis_weighting", 0.0 + ), + "Analysis Mode": factor.get("Factor Aggregation", ""), + "Result": factor.get("Result", ""), + "Execution Start Time": factor.get("Execution Start Time", ""), + "Factor Result File": factor.get("Factor Result File", ""), + "Execution End Time": factor.get("Execution End Time", ""), + } + status = ( + "🔴" if "Workflow Completed" not in factor_attributes["Result"] else "✔️" + ) + + factor_item = JsonTreeItem( + [factor["name"], status, "", factor_attributes], "factor", parent_item + ) + parent_item.appendChild(factor_item) + + return factor_item + + def _create_indicator_item(self, indicator, parent_item): + """ + Creates a new Indicator (layer) item under the specified Factor item and populates it with custom attributes. + + Args: + indicator (dict): The indicator (layer) data to be added to the tree. + parent_item (JsonTreeItem): The parent item (Factor) under which the indicator is added. + + Returns: + None + """ + status = ( + "🔴" + if "Workflow Completed" not in indicator.get("Indicator Result", "") + else "✔️" + ) + indicator_item = JsonTreeItem( + [ + indicator["Layer"], + status, + indicator.get("Factor Weighting", 0), + indicator, + ], + "layer", + parent_item, + ) + parent_item.appendChild(indicator_item) - # Set the factor's total weighting - factor_item.setData(2, f"{factor_weighting_sum:.2f}") - self.update_font_color( - factor_item, - QColor(Qt.green if factor_weighting_sum == 1.0 else Qt.red), - ) + def data(self, index, role): + """ + Provides data for the given index and role, including displaying custom attributes such as the font color, + icons, and font style. - self.endResetModel() + Args: + index (QModelIndex): The index for which data is requested. + role (int): The role (e.g., Qt.DisplayRole, Qt.ForegroundRole, etc.). - def data(self, index, role): + Returns: + QVariant: The data for the given index and role. + """ if not index.isValid(): return None item = index.internalPointer() - # Set the display text if role == Qt.DisplayRole: return item.data(index.column()) - - # Set the font color for weightings elif role == Qt.ForegroundRole and index.column() == 2: return item.font_color - - # Set the icon elif role == Qt.DecorationRole and index.column() == 0: return item.get_icon() - - # Set the font elif role == Qt.FontRole: return item.get_font() return None def setData(self, index, value, role=Qt.EditRole): - """Handle editing of values in the tree.""" + """ + Sets the data for the specified index and role, handling value validation (e.g., ensuring weightings are numbers). + + Args: + index (QModelIndex): The index of the item being edited. + value (any): The new value to set. + role (int): The role in which the value is being set (usually Qt.EditRole). + + Returns: + bool: True if the value was successfully set, False otherwise. + """ if role == Qt.EditRole: item = index.internalPointer() column = index.column() - # Allow editing for the weighting column (index 2) - if column == 2: + if column == 2: # Weighting column try: - # Ensure the value is a valid floating-point number value = float(value) - # Update the weighting value return item.setData(column, f"{value:.2f}") except ValueError: - # Show an error if the value is not valid QMessageBox.critical( None, "Invalid Value", @@ -181,46 +257,53 @@ def setData(self, index, value, role=Qt.EditRole): ) return False - # For other columns (like the name), we allow regular editing return item.setData(column, value) return False def flags(self, index): - """Allow editing of the name and weighting columns.""" + """ + Specifies the flags for the items in the model, controlling which items can be selected and edited. - # Override the flags method to allow specific columns to be editable. + Args: + index (QModelIndex): The index of the item. + Returns: + Qt.ItemFlags: The flags that determine the properties of the item (editable, selectable, etc.). + """ if not index.isValid(): return Qt.NoItemFlags item = index.internalPointer() - # For example, only allow editing for the first and second columns if index.column() == 0 or index.column() == 1: return Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsEnabled return Qt.ItemIsSelectable | Qt.ItemIsEnabled - def update_font_color(self, item, color): - """Update the font color of an item.""" - item.font_color = color - self.layoutChanged.emit() - def to_json(self): - """Convert the tree structure back into a JSON document.""" + """ + Converts the tree structure back into a JSON document, recursively traversing the tree and including + the custom attributes stored in `data(3)` for each item. + + Returns: + dict: The JSON representation of the tree structure. + """ def recurse_tree(item): - if item.role == "dimension": + if item.role == "analysis": + json = { + "analysis_name": item.data(3)["Analysis Name"], + "description": item.data(3)["Description"], + "working_folder": item.data(3)["Working Folder"], + "dimensions": [recurse_tree(child) for child in item.childItems], + } + return json + elif item.role == "dimension": json = { "name": item.data(0).lower(), "factors": [recurse_tree(child) for child in item.childItems], "Analysis Weighting": item.data(2), } - try: - json.update( - item.data(3) - ) # merges in the data stored in the third column - except: - pass + json.update(item.data(3)) return json elif item.role == "factor": json = { @@ -228,35 +311,48 @@ def recurse_tree(item): "layers": [recurse_tree(child) for child in item.childItems], "Dimension Weighting": item.data(2), } - try: - json.update( - item.data(3) - ) # merges in the data stored in the third column - except: - pass + json.update(item.data(3)) return json elif item.role == "layer": json = item.data(3) json["Factor Weighting"] = item.data(2) return json - json_data = { - "dimensions": [recurse_tree(child) for child in self.rootItem.childItems] - } + json_data = recurse_tree( + self.rootItem.child(0) + ) # Start with the "Analysis" item return json_data def clear_factor_weightings(self, dimension_item): - """Clear all weightings for factors under the given dimension.""" + """ + Clears all weightings for factors under the given dimension item, setting them to "0.00". + Also updates the dimension's total weighting and font color to red. + + Args: + dimension_item (JsonTreeItem): The dimension item whose factors will have their weightings cleared. + + Returns: + None + """ for i in range(dimension_item.childCount()): factor_item = dimension_item.child(i) factor_item.setData(2, "0.00") - # After clearing, update the dimension's total weighting + # Update the dimension's total weighting dimension_item.setData(2, "0.00") self.update_font_color(dimension_item, QColor(Qt.red)) self.layoutChanged.emit() def auto_assign_factor_weightings(self, dimension_item): - """Auto-assign weightings evenly across all factors under the dimension.""" + """ + Automatically assigns weightings evenly across all factors under the given dimension. + The total weighting will be divided evenly among the factors. + + Args: + dimension_item (JsonTreeItem): The dimension item whose factors will receive auto-assigned weightings. + + Returns: + None + """ num_factors = dimension_item.childCount() if num_factors == 0: return @@ -264,23 +360,41 @@ def auto_assign_factor_weightings(self, dimension_item): for i in range(num_factors): factor_item = dimension_item.child(i) factor_item.setData(2, f"{factor_weighting:.2f}") - # Update the dimensions's total weighting + # Update the dimension's total weighting dimension_item.setData(2, "1.00") self.update_font_color(dimension_item, QColor(Qt.green)) self.layoutChanged.emit() def clear_layer_weightings(self, factor_item): - """Clear all weightings for layers under the given factor.""" + """ + Clears all weightings for layers (indicators) under the given factor item, setting them to "0.00". + Also updates the factor's total weighting and font color to red. + + Args: + factor_item (JsonTreeItem): The factor item whose layers will have their weightings cleared. + + Returns: + None + """ for i in range(factor_item.childCount()): layer_item = factor_item.child(i) layer_item.setData(2, "0.00") - # After clearing, update the factor's total weighting + # Update the factor's total weighting factor_item.setData(2, "0.00") self.update_font_color(factor_item, QColor(Qt.red)) self.layoutChanged.emit() def auto_assign_layer_weightings(self, factor_item): - """Auto-assign weightings evenly across all layers under the factor.""" + """ + Automatically assigns weightings evenly across all layers under the given factor. + The total weighting will be divided evenly among the layers. + + Args: + factor_item (JsonTreeItem): The factor item whose layers will receive auto-assigned weightings. + + Returns: + None + """ num_layers = factor_item.childCount() if num_layers == 0: return @@ -294,25 +408,58 @@ def auto_assign_layer_weightings(self, factor_item): self.layoutChanged.emit() def add_factor(self, dimension_item): - """Add a new factor under the given dimension.""" + """ + Adds a new Factor item under the given Dimension item, allowing the user to define a new factor. + + Args: + dimension_item (JsonTreeItem): The dimension item to which the new factor will be added. + + Returns: + None + """ new_factor = JsonTreeItem(["New Factor", "🔴", ""], "factor", dimension_item) dimension_item.appendChild(new_factor) self.layoutChanged.emit() def add_layer(self, factor_item): - """Add a new layer under the given factor.""" + """ + Adds a new Layer (Indicator) item under the given Factor item, allowing the user to define a new layer. + + Args: + factor_item (JsonTreeItem): The factor item to which the new layer will be added. + + Returns: + None + """ new_layer = JsonTreeItem(["New Layer", "🔴", "1.00"], "layer", factor_item) factor_item.appendChild(new_layer) self.layoutChanged.emit() def remove_item(self, item): - """Remove the given item from its parent.""" + """ + Removes the given item from its parent. If the item has children, they are also removed. + + Args: + item (JsonTreeItem): The item to be removed from the tree. + + Returns: + None + """ parent = item.parent() if parent: parent.childItems.remove(item) self.layoutChanged.emit() def rowCount(self, parent=QModelIndex()): + """ + Returns the number of child items for the given parent. + + Args: + parent (QModelIndex): The parent index. + + Returns: + int: The number of child items under the parent. + """ if not parent.isValid(): parentItem = self.rootItem else: @@ -320,10 +467,29 @@ def rowCount(self, parent=QModelIndex()): return parentItem.childCount() def columnCount(self, parent=QModelIndex()): + """ + Returns the number of columns in the model. The number of columns is fixed to match the root item. + + Args: + parent (QModelIndex): The parent index. + + Returns: + int: The number of columns in the model. + """ return self.rootItem.columnCount() def index(self, row, column, parent=QModelIndex()): - """Create a QModelIndex for the specified row and column.""" + """ + Creates a QModelIndex for the specified row and column under the given parent. + + Args: + row (int): The row of the child item. + column (int): The column of the child item. + parent (QModelIndex): The parent index. + + Returns: + QModelIndex: The created index. + """ if not self.hasIndex(row, column, parent): return QModelIndex() @@ -338,7 +504,15 @@ def index(self, row, column, parent=QModelIndex()): return QModelIndex() def parent(self, index): - """Return the parent of the QModelIndex.""" + """ + Returns the parent index of the specified index. + + Args: + index (QModelIndex): The child index. + + Returns: + QModelIndex: The parent index. + """ if not index.isValid(): return QModelIndex() @@ -351,18 +525,46 @@ def parent(self, index): return self.createIndex(parentItem.row(), 0, parentItem) def headerData(self, section, orientation, role=Qt.DisplayRole): + """ + Provides the data for the header at the given section and orientation. + + Args: + section (int): The section (column) for which header data is requested. + orientation (Qt.Orientation): The orientation of the header (horizontal or vertical). + role (int): The role for which header data is requested. + + Returns: + QVariant: The data for the header. + """ if orientation == Qt.Horizontal and role == Qt.DisplayRole: return self.rootItem.data(section) return None def add_dimension(self, name="New Dimension"): - """Add a new dimension to the root and allow editing.""" + """ + Adds a new Dimension item to the root (under "Analysis") and allows the user to define a new dimension. + + Args: + name (str): The name of the new dimension. + + Returns: + None + """ new_dimension = JsonTreeItem([name, "🔴", ""], "dimension", self.rootItem) self.rootItem.appendChild(new_dimension) self.layoutChanged.emit() def removeRow(self, row, parent=QModelIndex()): - """Allow removing dimensions.""" + """ + Removes the specified row from the model. This is primarily used for removing dimensions. + + Args: + row (int): The row to be removed. + parent (QModelIndex): The parent index. + + Returns: + bool: True if the row was successfully removed, False otherwise. + """ parentItem = self.rootItem if not parent.isValid() else parent.internalPointer() parentItem.childItems.pop(row) self.layoutChanged.emit() diff --git a/geest/gui/widgets/point_and_polyline_widget.py b/geest/gui/widgets/point_and_polyline_widget.py new file mode 100644 index 0000000..0b64e83 --- /dev/null +++ b/geest/gui/widgets/point_and_polyline_widget.py @@ -0,0 +1,246 @@ +from qgis.PyQt.QtWidgets import ( + QLabel, + QVBoxLayout, + QHBoxLayout, + QLineEdit, + QToolButton, + QFileDialog, +) +from qgis.gui import QgsMapLayerComboBox +from qgis.core import QgsMessageLog, QgsMapLayerProxyModel, QgsProject, QgsVectorLayer +from qgis.PyQt.QtCore import QSettings +import os + +from .base_indicator_widget import BaseIndicatorWidget + + +class PointAndPolylineWidget(BaseIndicatorWidget): + """ + A widget for selecting a point layer and a polyline (paths) layer with options for shapefile inputs. + + This widget provides two `QgsMapLayerComboBox` components for selecting the point and polyline layers, + as well as `QLineEdit` and `QToolButton` components to allow the user to specify shapefile paths for + each layer. The user can choose layers either from the QGIS project or provide external shapefiles. + + Attributes: + widget_key (str): The key identifier for this widget. + point_layer_combo (QgsMapLayerComboBox): A combo box for selecting the point layer. + point_shapefile_line_edit (QLineEdit): Line edit for entering/selecting a point layer shapefile. + polyline_layer_combo (QgsMapLayerComboBox): A combo box for selecting the polyline layer. + polyline_shapefile_line_edit (QLineEdit): Line edit for entering/selecting a polyline layer shapefile. + """ + + def add_internal_widgets(self) -> None: + """ + Adds the internal widgets required for selecting point and polyline layers and their corresponding shapefiles. + This method is called during the widget initialization and sets up the layout for the UI components. + """ + try: + self.main_layout = QVBoxLayout() + self.widget_key = "Point and Polyline per Cell" + + # Point Layer Section + self._add_point_layer_widgets() + + # Polyline Layer Section + self._add_polyline_layer_widgets() + + # Add the main layout to the widget's layout + self.layout.addLayout(self.main_layout) + + # Connect signals to update the data when user changes selections + self.point_layer_combo.currentIndexChanged.connect(self.update_data) + self.point_shapefile_line_edit.textChanged.connect(self.update_data) + self.polyline_layer_combo.currentIndexChanged.connect(self.update_data) + self.polyline_shapefile_line_edit.textChanged.connect(self.update_data) + + except Exception as e: + QgsMessageLog.logMessage(f"Error in add_internal_widgets: {e}", "Geest") + import traceback + + QgsMessageLog.logMessage(traceback.format_exc(), "Geest") + + def _add_point_layer_widgets(self) -> None: + """ + Adds the widgets for selecting the point layer, including a `QgsMapLayerComboBox` and shapefile input. + """ + self.point_layer_label = QLabel("Point Layer - shapefile will have preference") + self.main_layout.addWidget(self.point_layer_label) + + # Point Layer ComboBox (Filtered to point layers) + self.point_layer_combo = QgsMapLayerComboBox() + self.point_layer_combo.setFilters(QgsMapLayerProxyModel.PointLayer) + self.main_layout.addWidget(self.point_layer_combo) + + # Restore previously selected point layer + point_layer_id = self.attributes.get(f"{self.widget_key} Point Layer ID", None) + if point_layer_id: + point_layer = QgsProject.instance().mapLayer(point_layer_id) + if point_layer: + self.point_layer_combo.setLayer(point_layer) + + # Shapefile Input for Point Layer + self.point_shapefile_layout = QHBoxLayout() + self.point_shapefile_line_edit = QLineEdit() + self.point_shapefile_button = QToolButton() + self.point_shapefile_button.setText("...") + self.point_shapefile_button.clicked.connect(self.select_point_shapefile) + if self.attributes.get(f"{self.widget_key} Point Shapefile", False): + self.point_shapefile_line_edit.setText( + self.attributes[f"{self.widget_key} Point Shapefile"] + ) + self.point_shapefile_layout.addWidget(self.point_shapefile_line_edit) + self.point_shapefile_layout.addWidget(self.point_shapefile_button) + self.main_layout.addLayout(self.point_shapefile_layout) + + def _add_polyline_layer_widgets(self) -> None: + """ + Adds the widgets for selecting the polyline layer, including a `QgsMapLayerComboBox` and shapefile input. + """ + self.polyline_layer_label = QLabel( + "Polyline Layer - shapefile will have preference" + ) + self.main_layout.addWidget(self.polyline_layer_label) + + # Polyline Layer ComboBox (Filtered to line layers) + self.polyline_layer_combo = QgsMapLayerComboBox() + self.polyline_layer_combo.setFilters(QgsMapLayerProxyModel.LineLayer) + self.main_layout.addWidget(self.polyline_layer_combo) + + # Restore previously selected polyline layer + polyline_layer_id = self.attributes.get( + f"{self.widget_key} Polyline Layer ID", None + ) + if polyline_layer_id: + polyline_layer = QgsProject.instance().mapLayer(polyline_layer_id) + if polyline_layer: + self.polyline_layer_combo.setLayer(polyline_layer) + + # Shapefile Input for Polyline Layer + self.polyline_shapefile_layout = QHBoxLayout() + self.polyline_shapefile_line_edit = QLineEdit() + self.polyline_shapefile_button = QToolButton() + self.polyline_shapefile_button.setText("...") + self.polyline_shapefile_button.clicked.connect(self.select_polyline_shapefile) + if self.attributes.get(f"{self.widget_key} Polyline Shapefile", False): + self.polyline_shapefile_line_edit.setText( + self.attributes[f"{self.widget_key} Polyline Shapefile"] + ) + self.polyline_shapefile_layout.addWidget(self.polyline_shapefile_line_edit) + self.polyline_shapefile_layout.addWidget(self.polyline_shapefile_button) + self.main_layout.addLayout(self.polyline_shapefile_layout) + + def select_point_shapefile(self) -> None: + """ + Opens a file dialog to select a shapefile for the point layer and updates the QLineEdit with the file path. + """ + try: + settings = QSettings() + last_dir = settings.value("Geest/lastShapefileDir", "") + + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Point Shapefile", last_dir, "Shapefiles (*.shp)" + ) + if file_path: + self.point_shapefile_line_edit.setText(file_path) + settings.setValue("Geest/lastShapefileDir", os.path.dirname(file_path)) + + except Exception as e: + QgsMessageLog.logMessage(f"Error selecting point shapefile: {e}", "Geest") + + def select_polyline_shapefile(self) -> None: + """ + Opens a file dialog to select a shapefile for the polyline (paths) layer and updates the QLineEdit with the file path. + """ + try: + settings = QSettings() + last_dir = settings.value("Geest/lastShapefileDir", "") + + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Polyline Shapefile", last_dir, "Shapefiles (*.shp)" + ) + if file_path: + self.polyline_shapefile_line_edit.setText(file_path) + settings.setValue("Geest/lastShapefileDir", os.path.dirname(file_path)) + + except Exception as e: + QgsMessageLog.logMessage( + f"Error selecting polyline shapefile: {e}", "Geest" + ) + + def get_data(self) -> dict: + """ + Retrieves and returns the current state of the widget, including selected point and polyline layers or shapefiles. + + Returns: + dict: A dictionary containing the current attributes of the point and polyline layers and/or shapefiles. + """ + if not self.isChecked(): + return None + + # Collect data for the point layer + point_layer = self.point_layer_combo.currentLayer() + if point_layer: + self.attributes[f"{self.widget_key} Point Layer Name"] = point_layer.name() + self.attributes[f"{self.widget_key} Point Layer Source"] = ( + point_layer.source() + ) + self.attributes[f"{self.widget_key} Point Layer Provider Type"] = ( + point_layer.providerType() + ) + self.attributes[f"{self.widget_key} Point Layer CRS"] = ( + point_layer.crs().authid() + ) + self.attributes[f"{self.widget_key} Point Layer Wkb Type"] = ( + point_layer.wkbType() + ) + self.attributes[f"{self.widget_key} Point Layer ID"] = point_layer.id() + self.attributes[f"{self.widget_key} Point Shapefile"] = ( + self.point_shapefile_line_edit.text() + ) + + # Collect data for the polyline layer + polyline_layer = self.polyline_layer_combo.currentLayer() + if polyline_layer: + self.attributes[f"{self.widget_key} Polyline Layer Name"] = ( + polyline_layer.name() + ) + self.attributes[f"{self.widget_key} Polyline Layer Source"] = ( + polyline_layer.source() + ) + self.attributes[f"{self.widget_key} Polyline Layer Provider Type"] = ( + polyline_layer.providerType() + ) + self.attributes[f"{self.widget_key} Polyline Layer CRS"] = ( + polyline_layer.crs().authid() + ) + self.attributes[f"{self.widget_key} Polyline Layer Wkb Type"] = ( + polyline_layer.wkbType() + ) + self.attributes[f"{self.widget_key} Polyline Layer ID"] = ( + polyline_layer.id() + ) + self.attributes[f"{self.widget_key} Polyline Shapefile"] = ( + self.polyline_shapefile_line_edit.text() + ) + + return self.attributes + + def set_internal_widgets_enabled(self, enabled: bool) -> None: + """ + Enables or disables the internal widgets (both point and polyline layers) based on the state of the radio button. + + Args: + enabled (bool): Whether to enable or disable the internal widgets. + """ + try: + self.point_layer_combo.setEnabled(enabled) + self.point_shapefile_line_edit.setEnabled(enabled) + self.point_shapefile_button.setEnabled(enabled) + self.polyline_layer_combo.setEnabled(enabled) + self.polyline_shapefile_line_edit.setEnabled(enabled) + self.polyline_shapefile_button.setEnabled(enabled) + except Exception as e: + QgsMessageLog.logMessage( + f"Error in set_internal_widgets_enabled: {e}", "Geest" + ) diff --git a/geest/resources/model.json b/geest/resources/model.json index 339bebf..0efd311 100644 --- a/geest/resources/model.json +++ b/geest/resources/model.json @@ -157,6 +157,7 @@ "Use Poly per Cell": 0, "Use Polyline per Cell": 0, "Use Point per Cell": 0, + "Use Point and Polyline per Cell": 1, "Analysis Mode": "Don\u2019t Use", "Layer Required": 1 }, From f20515c02a479ebb8660f3503c2f58413973dd5d Mon Sep 17 00:00:00 2001 From: Tim Sutton Date: Fri, 11 Oct 2024 08:58:28 +0100 Subject: [PATCH 2/2] Bump version to 1.4 and fix index score input range --- config.json | 2 +- geest/gui/views/treeview.py | 2 -- geest/gui/widgets/indicator_index_score_widget.py | 1 + 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/config.json b/config.json index cb81b40..f3639b5 100644 --- a/config.json +++ b/config.json @@ -16,7 +16,7 @@ "author": "Kartoza", "email": "info@kartoza.com", "description": "Gender Enabling Environments Spatial Tool", - "version": "0.1.1", + "version": "0.1.4", "changelog": "", "server": false } diff --git a/geest/gui/views/treeview.py b/geest/gui/views/treeview.py index b28af8c..f5cbfae 100644 --- a/geest/gui/views/treeview.py +++ b/geest/gui/views/treeview.py @@ -22,11 +22,9 @@ from geest.utilities import resources_path from geest.core import JsonTreeItem - from qgis.PyQt.QtWidgets import QAbstractItemDelegate, QTreeView, QMessageBox from qgis.PyQt.QtCore import QAbstractItemModel, QModelIndex, Qt from PyQt5.QtGui import QColor -from geest.core import JsonTreeItem class JsonTreeModel(QAbstractItemModel): diff --git a/geest/gui/widgets/indicator_index_score_widget.py b/geest/gui/widgets/indicator_index_score_widget.py index e828002..20196ca 100644 --- a/geest/gui/widgets/indicator_index_score_widget.py +++ b/geest/gui/widgets/indicator_index_score_widget.py @@ -15,6 +15,7 @@ def add_internal_widgets(self) -> None: try: self.info_label: QLabel = QLabel(self.label_text) self.index_input: QDoubleSpinBox = QDoubleSpinBox() + self.index_input.setRange(0, 100) self.layout.addWidget(self.info_label) self.layout.addWidget(self.index_input) self.index_input.setValue(self.attributes["Default Index Score"])