From ba7340950269f61fbed029d9fc507ff2018a7a58 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:17:45 +0930 Subject: [PATCH 01/17] test: add unit tests for ConfigurationModel --- .../unit/gui/test_ntgs_configuration_model.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/unit/gui/test_ntgs_configuration_model.py diff --git a/tests/unit/gui/test_ntgs_configuration_model.py b/tests/unit/gui/test_ntgs_configuration_model.py new file mode 100644 index 0000000..997ede9 --- /dev/null +++ b/tests/unit/gui/test_ntgs_configuration_model.py @@ -0,0 +1,33 @@ +import pytest + +from loopstructural.gui.data_conversion.configuration import NtgsConfig, NtgsConfigurationModel + + +def test_model_returns_deep_copy(): + base = NtgsConfig().as_dict() + model = NtgsConfigurationModel(base_config=base) + + exported = model.as_dict() + exported["geology"]["unitname_column"] = "CustomFormation" + + assert model.get_value("geology", "unitname_column") == base["geology"]["unitname_column"] + + +def test_set_value_coerces_lists(): + model = NtgsConfigurationModel() + model.set_value("geology", "ignore_lithology_codes", "cover, Unknown , ,") + + assert model.get_value("geology", "ignore_lithology_codes") == ["cover", "Unknown"] + + +def test_update_values_casts_to_string(): + model = NtgsConfigurationModel() + model.update_values("fault", {"dip_null_value": -123}) + + assert model.get_value("fault", "dip_null_value") == "-123" + + +def test_unknown_data_type_raises(): + model = NtgsConfigurationModel() + with pytest.raises(KeyError): + model.set_value("unknown", "some_field", "value") From f0381eb25d984adbcf729a62acbe3dec1405c28e Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:17:56 +0930 Subject: [PATCH 02/17] feat: add DataConversionWidget --- loopstructural/gui/loop_widget.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index bf78f50..b5b9da0 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -6,6 +6,8 @@ """ from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget + +from .data_conversion import DataConversionWidget from .modelling.modelling_widget import ModellingWidget from .visualisation.visualisation_widget import VisualisationWidget @@ -49,7 +51,13 @@ def __init__( self.visualisation_widget = VisualisationWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, model_manager=self.model_manager ) + self.data_conversion_widget = DataConversionWidget( + self, + data_manager=self.data_manager, + project=self.data_manager.project if self.data_manager else None, + ) tabWidget.addTab(self.modelling_widget, "Modelling") + tabWidget.addTab(self.data_conversion_widget, "Data Conversion") tabWidget.addTab(self.visualisation_widget, "Visualisation") def get_modelling_widget(self): @@ -71,3 +79,7 @@ def get_visualisation_widget(self): The visualisation widget. """ return self.visualisation_widget + + def get_data_conversion_widget(self): + """Return the data conversion widget instance.""" + return self.data_conversion_widget From 62807e7936c4426c69dc106044ee9ecd03e95dbb Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:18:05 +0930 Subject: [PATCH 03/17] feat: add data conversion GUI components --- loopstructural/gui/data_conversion/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 loopstructural/gui/data_conversion/__init__.py diff --git a/loopstructural/gui/data_conversion/__init__.py b/loopstructural/gui/data_conversion/__init__.py new file mode 100644 index 0000000..fd12735 --- /dev/null +++ b/loopstructural/gui/data_conversion/__init__.py @@ -0,0 +1,5 @@ +"""Data conversion GUI components.""" + +from .data_conversion_widget import DataConversionWidget + +__all__ = ["DataConversionWidget"] From a195cc169fe76bb4798adb0f7614f89f4cc1bc4f Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:18:32 +0930 Subject: [PATCH 04/17] feat: implement data conversion configuration helpers --- .../gui/data_conversion/configuration.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 loopstructural/gui/data_conversion/configuration.py diff --git a/loopstructural/gui/data_conversion/configuration.py b/loopstructural/gui/data_conversion/configuration.py new file mode 100644 index 0000000..f926494 --- /dev/null +++ b/loopstructural/gui/data_conversion/configuration.py @@ -0,0 +1,149 @@ +"""NTGS configuration helpers used by the data conversion UI.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Dict, Iterable, MutableMapping + + +class NtgsConfig: + """Container for the default NTGS configuration.""" + + def __init__(self) -> None: + self.fold_config = { + "structtype_column": "FoldType", + "fold_text": "'Anticline','Syncline','Antiform','Synform','Monocline','Monoform','Neutral','Fold axis','Overturned syncline'", + "description_column": "Desc", + "synform_text": "FoldType", + "foldname_column": "FoldName", + "objectid_column": "OBJECTID", + "tightness_column": "IntlimbAng", + "axial_plane_dipdir_column": "AxPlDipDir", + "axial_plane_dip_column": "AxPlDip", + } + + self.fault_config = { + "orientation_type": "dip direction", + "structtype_column": "FaultType", + "fault_text": "'Thrust','Reverse','Normal','Shear zone','Strike-slip','Thrust','Unknown'", + "dip_null_value": "-999", + "dipdir_flag": "num", + "dipdir_column": "DipDir", + "dip_column": "Dip", + "dipestimate_column": "DipEstimate", + "dipestimate_text": "'NORTH_EAST','NORTH',,'NOT ACCESSED'", + "displacement_column": "Displace", + "displacement_text": "'1m-100m', '100m-1km', '1km-5km', '>5km'", + "fault_length_column": "FaultLen", + "fault_length_text": "Small (0-5km),Medium (5-30km),Large (30-100km),Regional (>100km),Unclassified", + "name_column": "FaultName", + "objectid_column": "OBJECTID", + } + + self.geology_config = { + "unitname_column": "Formation", + "alt_unitname_column": "Formation", + "group_column": "Group", + "supergroup_column": "Supergroup", + "description_column": "LithDescn1", + "minage_column": "AgeMin", + "maxage_column": "AgeMax", + "rocktype_column": "LithClass", + "alt_rocktype_column": "RockCat", + "sill_text": "RockCat", + "intrusive_text": "RockCat", + "volcanic_text": "RockCat", + "objectid_column": "OBJECTID", + "ignore_lithology_codes": ["cover", "Unknown"], + } + + self.structure_config = { + "orientation_type": "dip direction", + "dipdir_column": "DipDir", + "dip_column": "Dip", + "description_column": "FeatDesc", + "bedding_text": "ObsType", + "overturned_column": "Desc", + "overturned_text": "overturned", + "objectid_column": "OBJECTID", + } + + self.config_map = { + "geology": self.geology_config, + "structure": self.structure_config, + "fault": self.fault_config, + "fold": self.fold_config, + } + + def __getitem__(self, datatype: str) -> Dict[str, Any]: + return self.config_map[datatype] + + def as_dict(self) -> Dict[str, Dict[str, Any]]: + """Return a deep copy of the configuration map.""" + return deepcopy(self.config_map) + + +def _coerce_config_value(template_value: Any, new_value: Any) -> Any: + """Coerce user supplied values into the template format.""" + if isinstance(template_value, list): + if isinstance(new_value, list): + return new_value + if new_value in (None, ""): + return [] + if isinstance(new_value, str): + return [item.strip() for item in new_value.split(",") if item.strip()] + return [str(new_value)] + + if isinstance(template_value, (int, float)): + try: + return type(template_value)(new_value) + except (TypeError, ValueError): + return template_value + + if template_value is None: + return new_value + + if new_value is None: + return "" + + return str(new_value) + + +class NtgsConfigurationModel: + """State holder for the NTGS configuration mapping.""" + + def __init__(self, *, base_config: MutableMapping[str, Dict[str, Any]] | None = None): + self._config = deepcopy(base_config) if base_config is not None else NtgsConfig().as_dict() + + def data_types(self) -> Iterable[str]: + """Return the supported data types.""" + return self._config.keys() + + def get_config_for_type(self, data_type: str) -> Dict[str, Any]: + """Return a copy of the configuration for a single data type.""" + self._ensure_data_type(data_type) + return deepcopy(self._config[data_type]) + + def set_value(self, data_type: str, key: str, value: Any) -> None: + """Update a single configuration entry.""" + self._ensure_data_type(data_type) + template_value = self._config[data_type].get(key) + self._config[data_type][key] = _coerce_config_value(template_value, value) + + def update_values(self, data_type: str, updates: Dict[str, Any]) -> None: + """Bulk update configuration entries for a type.""" + for key, value in updates.items(): + self.set_value(data_type, key, value) + + def get_value(self, data_type: str, key: str) -> Any: + """Return the stored value for a configuration entry.""" + self._ensure_data_type(data_type) + return self._config[data_type].get(key) + + def as_dict(self) -> Dict[str, Dict[str, Any]]: + """Return a deep copy of the entire configuration map.""" + return deepcopy(self._config) + + def _ensure_data_type(self, data_type: str) -> None: + if data_type not in self._config: + raise KeyError(f"Unknown data type '{data_type}'") From b1e81123cbd9b091772818963b107e3c8cf3aed8 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:19:05 +0930 Subject: [PATCH 05/17] feat: DataConversionWidget implemenation --- .../data_conversion/data_conversion_widget.py | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 loopstructural/gui/data_conversion/data_conversion_widget.py diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py new file mode 100644 index 0000000..a98d7ca --- /dev/null +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -0,0 +1,349 @@ +"""Data conversion widget displayed inside the LoopStructural dock.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QComboBox, + QFormLayout, + QLabel, + QLineEdit, + QScrollArea, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) +from qgis.core import QgsMapLayer, QgsMapLayerProxyModel, QgsProject, QgsVectorLayer +from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox + +from .configuration import NtgsConfigurationModel + + +@dataclass +class ConverterOption: + """Simple data container describing an available converter.""" + + identifier: str + label: str + description: str = "" + + def to_dict(self) -> Dict[str, str]: + """Return a serialisable representation of the option.""" + return { + "id": self.identifier, + "label": self.label, + "description": self.description, + } + + +def _normalise_converters(converters: Optional[Iterable[Any]]) -> List[ConverterOption]: + normalised: List[ConverterOption] = [] + if not converters: + return normalised + + for raw in converters: + if isinstance(raw, ConverterOption): + normalised.append(raw) + continue + + if isinstance(raw, Mapping): + identifier = str( + raw.get("id") + or raw.get("identifier") + or raw.get("name") + or raw.get("label") + or "converter" + ) + label = str(raw.get("label") or raw.get("name") or identifier) + description = str(raw.get("description") or "") + normalised.append(ConverterOption(identifier=identifier, label=label, description=description)) + continue + + text = str(raw) + normalised.append(ConverterOption(identifier=text, label=text, description="")) + return normalised + + +class AutomaticConversionWidget(QWidget): + """Widget showing the automatic conversion workflow.""" + + def __init__(self, parent: Optional[QWidget] = None, *, converters: Optional[Iterable[Any]] = None): + super().__init__(parent) + self._options: List[ConverterOption] = [] + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + description = QLabel( + "Automatically run one of the available converters on the selected data sources." + ) + description.setWordWrap(True) + layout.addWidget(description) + + self.converter_combo = QComboBox() + self.converter_combo.setToolTip("Select the converter implementation to run.") + layout.addWidget(self.converter_combo) + + self.summary_text = QTextEdit() + self.summary_text.setReadOnly(True) + self.summary_text.setMinimumHeight(80) + layout.addWidget(self.summary_text) + + self.set_converters(converters) + self.converter_combo.currentIndexChanged.connect(self._update_summary_text) + + def set_converters(self, converters: Optional[Iterable[Any]]) -> None: + """Populate the dropdown with the converters supplied by the backend.""" + self._options = _normalise_converters(converters) + self.converter_combo.clear() + for option in self._options: + self.converter_combo.addItem(option.label, option.identifier) + if not self._options: + self.converter_combo.addItem("No converters available") + self.converter_combo.setEnabled(False) + else: + self.converter_combo.setEnabled(True) + self._update_summary_text() + + def current_converter(self) -> Optional[ConverterOption]: + """Return the active converter option, if any.""" + index = self.converter_combo.currentIndex() + if index < 0 or index >= len(self._options): + return None + return self._options[index] + + def _update_summary_text(self) -> None: + option = self.current_converter() + if option is None: + self.summary_text.setPlainText( + "No converter selected. Please configure converters in the plugin settings." + ) + else: + description = option.description or "No description provided for this converter." + self.summary_text.setPlainText(description) + + +class ManualConversionWidget(QWidget): + """Widget that lets the user map table columns to the NTGS configuration.""" + + def __init__( + self, + parent: Optional[QWidget] = None, + *, + config_model: Optional[NtgsConfigurationModel] = None, + project: Optional[QgsProject] = None, + ): + super().__init__(parent) + self.project = project or QgsProject.instance() + self.model = config_model or NtgsConfigurationModel() + self.data_types = list(self.model.data_types()) + if not self.data_types: + raise ValueError("Configuration model does not provide any data types.") + self.current_data_type = self.data_types[0] + self.layer_selections: Dict[str, Optional[Dict[str, str]]] = { + dtype: None for dtype in self.data_types + } + self.field_widgets: Dict[str, QWidget] = {} + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + instructions = QLabel( + "Manually map QGIS table columns to the fields expected by the NTGS configuration." + " Select a data type, choose the source table and assign each field." + ) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + self.data_type_combo = QComboBox() + for dtype in self.data_types: + self.data_type_combo.addItem(dtype.title(), dtype) + layout.addWidget(self.data_type_combo) + + self.layer_combo = QgsMapLayerComboBox() + self.layer_combo.setFilters(QgsMapLayerProxyModel.VectorLayer) + self.layer_combo.setAllowEmptyLayer(True) + layout.addWidget(self.layer_combo) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + layout.addWidget(self.scroll_area, stretch=1) + + self.form_widget = QWidget() + self.form_layout = QFormLayout(self.form_widget) + self.form_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop) + self.scroll_area.setWidget(self.form_widget) + + self.data_type_combo.currentIndexChanged.connect(self._handle_data_type_changed) + self.layer_combo.layerChanged.connect(self._handle_layer_changed) + + self._apply_layer_selection_for_current_type() + self._build_form() + + def _handle_data_type_changed(self, index: int) -> None: + next_data_type = self.data_type_combo.itemData(index) + if not next_data_type or next_data_type == self.current_data_type: + return + self._persist_current_values() + self.current_data_type = next_data_type + self._apply_layer_selection_for_current_type() + self._build_form() + + def _apply_layer_selection_for_current_type(self) -> None: + selection = self.layer_selections.get(self.current_data_type) + if not selection: + self.layer_combo.setCurrentIndex(-1) + return + layer = self._layer_from_selection(selection) + if layer is None: + self.layer_combo.setCurrentIndex(-1) + return + self.layer_combo.setLayer(layer) + + def _layer_from_selection(self, selection: Dict[str, str]) -> Optional[QgsMapLayer]: + if not selection: + return None + layer_id = selection.get("layer_id") + if not layer_id or self.project is None: + return None + return self.project.mapLayer(layer_id) + + def _handle_layer_changed(self, layer: Optional[QgsMapLayer]) -> None: + if layer and isinstance(layer, QgsVectorLayer): + self.layer_selections[self.current_data_type] = { + "layer_id": layer.id(), + "layer_name": layer.name(), + } + else: + self.layer_selections[self.current_data_type] = None + + for key, widget in self.field_widgets.items(): + if isinstance(widget, QgsFieldComboBox): + widget.setLayer(layer) + stored_value = self.model.get_value(self.current_data_type, key) + if stored_value: + widget.setField(stored_value) + else: + widget.setCurrentIndex(-1) + + def _build_form(self) -> None: + while self.form_layout.count(): + item = self.form_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + self.field_widgets.clear() + + config = self.model.get_config_for_type(self.current_data_type) + for key, default_value in config.items(): + widget = self._create_widget_for_entry(key, default_value) + self.form_layout.addRow(self._format_label(key), widget) + self.field_widgets[key] = widget + + def _create_widget_for_entry(self, key: str, value: Any) -> QWidget: + if key.endswith("_column"): + field_combo = QgsFieldComboBox() + field_combo.setLayer(self.layer_combo.currentLayer()) + if hasattr(field_combo, "setAllowEmptyFieldName"): + field_combo.setAllowEmptyFieldName(True) + stored = self.model.get_value(self.current_data_type, key) + if stored: + field_combo.setField(stored) + return field_combo + + line_edit = QLineEdit() + line_edit.setText(self._stringify_value(value)) + line_edit.setClearButtonEnabled(True) + return line_edit + + def _format_label(self, key: str) -> str: + parts = key.replace("_", " ").split() + return " ".join(part.capitalize() for part in parts) + + def _stringify_value(self, value: Any) -> str: + if isinstance(value, list): + return ", ".join(str(item) for item in value) + if value is None: + return "" + return str(value) + + def _persist_current_values(self) -> None: + if not self.field_widgets: + return + + updates: Dict[str, Any] = {} + for key, widget in self.field_widgets.items(): + if isinstance(widget, QgsFieldComboBox): + updates[key] = widget.currentField() or "" + elif isinstance(widget, QLineEdit): + updates[key] = widget.text() + self.model.update_values(self.current_data_type, updates) + + layer = self.layer_combo.currentLayer() + if layer and isinstance(layer, QgsVectorLayer): + self.layer_selections[self.current_data_type] = { + "layer_id": layer.id(), + "layer_name": layer.name(), + } + else: + self.layer_selections[self.current_data_type] = None + + def get_configuration(self) -> Dict[str, Dict[str, Any]]: + """Return the configuration map built from the user selections.""" + self._persist_current_values() + return self.model.as_dict() + + def get_layer_selections(self) -> Dict[str, Optional[Dict[str, str]]]: + """Return the per-data-type layer selections.""" + self._persist_current_values() + return {key: (value.copy() if value else None) for key, value in self.layer_selections.items()} + + +class DataConversionWidget(QWidget): + """High level widget that exposes tabs for automatic and manual conversion.""" + + def __init__( + self, + parent: Optional[QWidget] = None, + *, + data_manager: Any = None, + converters: Optional[Iterable[Any]] = None, + project: Optional[QgsProject] = None, + ): + super().__init__(parent) + self.data_manager = data_manager + self.project = project or QgsProject.instance() + + layout = QVBoxLayout(self) + description = QLabel("Convert geological datasets for use within LoopStructural.") + description.setWordWrap(True) + layout.addWidget(description) + + self.tab_widget = QTabWidget() + layout.addWidget(self.tab_widget) + + self.automatic_widget = AutomaticConversionWidget(self, converters=converters) + self.manual_widget = ManualConversionWidget(self, config_model=NtgsConfigurationModel(), project=self.project) + + self.tab_widget.addTab(self.automatic_widget, "Automatic") + self.tab_widget.addTab(self.manual_widget, "Manual") + + def set_converters(self, converters: Iterable[Any]) -> None: + """Update the converter options displayed in the automatic tab.""" + self.automatic_widget.set_converters(converters) + + def get_active_configuration(self) -> Dict[str, Any]: + """Return a serialisable summary of the current tab selection.""" + if self.tab_widget.currentWidget() is self.automatic_widget: + converter = self.automatic_widget.current_converter() + return { + "mode": "automatic", + "converter": converter.to_dict() if converter else None, + } + + return { + "mode": "manual", + "layers": self.manual_widget.get_layer_selections(), + "config_map": self.manual_widget.get_configuration(), + } From 40d2945e45264c40fe02828d8b8e806d44d4ccd8 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:45:45 +0930 Subject: [PATCH 06/17] fix: improve ui handling --- .../data_conversion/data_conversion_widget.py | 277 +++++++++++++++++- 1 file changed, 268 insertions(+), 9 deletions(-) diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index a98d7ca..77a8871 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -3,14 +3,16 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Mapping, Optional +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QComboBox, QFormLayout, + QHBoxLayout, QLabel, QLineEdit, + QPushButton, QScrollArea, QTabWidget, QTextEdit, @@ -20,7 +22,36 @@ from qgis.core import QgsMapLayer, QgsMapLayerProxyModel, QgsProject, QgsVectorLayer from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox -from .configuration import NtgsConfigurationModel +from ...main.vectorLayerWrapper import QgsLayerFromGeoDataFrame, qgsLayerToGeoDataFrame +from .configuration import ConfigurationState +from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName + +try: # geopandas is required for dataframe conversions, but guard import for safety + from geopandas import GeoDataFrame as _GeoDataFrameType +except Exception: # pragma: no cover - geopandas unavailable + _GeoDataFrameType = None + + +def _run_loop_conversion( + survey_name: SurveyName, data_sources: Mapping[Datatype | str, Any] +) -> Any: + """Execute the LoopDataConverter workflow for the supplied survey and data.""" + if not data_sources: + raise ValueError("At least one data source is required for conversion.") + + formatted_sources: Dict[str, Any] = {} + for data_type, dataset in data_sources.items(): + if dataset is None: + continue + key = data_type.name if isinstance(data_type, Datatype) else str(data_type) + formatted_sources[key.split(".")[-1].upper()] = dataset + + if not formatted_sources: + raise ValueError("Unable to run conversion without valid data sources.") + + input_data = InputData(**formatted_sources) + converter = LoopConverter(survey_name=survey_name, data=input_data) + return converter.convert() @dataclass @@ -67,13 +98,24 @@ def _normalise_converters(converters: Optional[Iterable[Any]]) -> List[Converter normalised.append(ConverterOption(identifier=text, label=text, description="")) return normalised - class AutomaticConversionWidget(QWidget): """Widget showing the automatic conversion workflow.""" - def __init__(self, parent: Optional[QWidget] = None, *, converters: Optional[Iterable[Any]] = None): + SUPPORTED_DATA_TYPES: Tuple[str, ...] = ("GEOLOGY", "STRUCTURE", "FAULT", "FOLD") + OUTPUT_GROUP_NAME = "Loop-Ready Data" + + def __init__( + self, + parent: Optional[QWidget] = None, + *, + converters: Optional[Iterable[Any]] = None, + project: Optional[QgsProject] = None, + ): super().__init__(parent) + self.project = project or QgsProject.instance() self._options: List[ConverterOption] = [] + self._data_types: List[Datatype | str] = self._discover_data_types() + self.layer_selectors: Dict[Datatype | str, QgsMapLayerComboBox] = {} layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) description = QLabel( @@ -91,20 +133,48 @@ def __init__(self, parent: Optional[QWidget] = None, *, converters: Optional[Ite self.summary_text.setMinimumHeight(80) layout.addWidget(self.summary_text) + source_description = QLabel("Select the layers that correspond to each dataset required by the converter.") + source_description.setWordWrap(True) + layout.addWidget(source_description) + + self.sources_widget = QWidget() + self.sources_layout = QFormLayout(self.sources_widget) + self.sources_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop) + layout.addWidget(self.sources_widget) + self._build_data_source_inputs() + + actions_widget = QWidget() + actions_layout = QHBoxLayout(actions_widget) + actions_layout.setContentsMargins(0, 0, 0, 0) + + self.run_button = QPushButton("Run Conversion") + self.run_button.clicked.connect(self._handle_run_conversion) + actions_layout.addWidget(self.run_button) + actions_layout.addStretch() + + self.status_label = QLabel("") + self.status_label.setObjectName("automaticConversionStatus") + actions_layout.addWidget(self.status_label) + + layout.addWidget(actions_widget) + self.set_converters(converters) self.converter_combo.currentIndexChanged.connect(self._update_summary_text) def set_converters(self, converters: Optional[Iterable[Any]]) -> None: """Populate the dropdown with the converters supplied by the backend.""" - self._options = _normalise_converters(converters) + available = converters if converters else self._default_converter_options() + self._options = _normalise_converters(available) self.converter_combo.clear() for option in self._options: self.converter_combo.addItem(option.label, option.identifier) if not self._options: self.converter_combo.addItem("No converters available") self.converter_combo.setEnabled(False) + self.run_button.setEnabled(False) else: self.converter_combo.setEnabled(True) + self.run_button.setEnabled(True) self._update_summary_text() def current_converter(self) -> Optional[ConverterOption]: @@ -124,6 +194,195 @@ def _update_summary_text(self) -> None: description = option.description or "No description provided for this converter." self.summary_text.setPlainText(description) + def _build_data_source_inputs(self) -> None: + while self.sources_layout.count(): + item = self.sources_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + self.layer_selectors.clear() + + for data_type in self._data_types: + combo = QgsMapLayerComboBox() + combo.setFilters(QgsMapLayerProxyModel.VectorLayer) + combo.setAllowEmptyLayer(True) + combo.setObjectName(f"automaticSource_{self._format_identifier_label(data_type)}") + self.sources_layout.addRow(self._format_identifier_label(data_type), combo) + self.layer_selectors[data_type] = combo + + def _collect_data_sources(self) -> Dict[Datatype | str, Any]: + data_sources: Dict[Datatype | str, Any] = {} + for data_type, combo in self.layer_selectors.items(): + layer = combo.currentLayer() + if layer and isinstance(layer, QgsVectorLayer) and layer.isValid(): + dataframe = qgsLayerToGeoDataFrame(layer) + if dataframe is not None: + data_sources[data_type] = dataframe + return data_sources + + def _handle_run_conversion(self) -> None: + converter = self.current_converter() + if converter is None: + self._update_status("Please select a converter before running.", error=True) + return + + sources = self._collect_data_sources() + if not sources: + self._update_status("Select at least one data source layer before running.", error=True) + return + + result: Any = None + added_layers = 0 + try: + survey = self._normalise_survey_name(converter.identifier) + result = self.run_conversion(survey, sources) + layers = self._materialise_layers_from_result(result) + if layers: + added_layers = self._add_layers_to_project_group(layers) + except Exception as exc: # pragma: no cover - UI feedback + self._update_status(f"Conversion failed: {exc}", error=True) + return + + if added_layers: + message = ( + f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'." + ) + elif result not in (None, True): + message = f"Conversion completed: {result}" + else: + message = "Conversion completed successfully." + self._update_status(message) + + def _update_status(self, message: str, *, error: bool = False) -> None: + color = "#c00000" if error else "#006400" + self.status_label.setStyleSheet(f"color: {color};") + self.status_label.setText(message) + + def run_conversion(self, survey_name: SurveyName | str, data_sources: Mapping[Datatype | str, Any]) -> Any: + """Execute the LoopConverter against the supplied dataset.""" + survey = self._normalise_survey_name(survey_name) + return _run_loop_conversion(survey, data_sources) + + @staticmethod + def _normalise_survey_name(value: SurveyName | str) -> SurveyName: + """Convert user-provided identifiers into a SurveyName enum.""" + if isinstance(value, SurveyName): + return value + key = str(value).strip() + if "." in key: + key = key.split(".", 1)[1] + try: + return SurveyName[key] + except KeyError: + pass + try: + return SurveyName[key.upper()] + except KeyError as exc: + raise ValueError(f"Unknown survey name '{value}'.") from exc + + def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]: + layers: List[QgsVectorLayer] = [] + self._collect_layers_from_result(result, layers, prefix="output") + return layers + + def _collect_layers_from_result( + self, payload: Any, layers: List[QgsVectorLayer], *, prefix: str + ) -> None: + if payload is None: + return + if isinstance(payload, Mapping): + for key, value in payload.items(): + label = str(key) + self._collect_layers_from_result(value, layers, prefix=label or prefix) + return + if isinstance(payload, (list, tuple, set)): + for index, value in enumerate(payload, start=1): + self._collect_layers_from_result(value, layers, prefix=f"{prefix}_{index}") + return + layer = self._vector_layer_from_value(prefix, payload) + if layer is not None and layer.isValid(): + layers.append(layer) + + def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorLayer]: + label = self._format_identifier_label(str(name)) + if isinstance(value, QgsVectorLayer): + value.setName(label) + return value + if _GeoDataFrameType is not None and isinstance(value, _GeoDataFrameType): + return QgsLayerFromGeoDataFrame(value, layer_name=label) + if isinstance(value, str): + layer = QgsVectorLayer(value, label, "ogr") + return layer if layer.isValid() else None + if isinstance(value, Mapping): + provider = str(value.get("provider") or "ogr") + nested_layer = value.get("layer") + if isinstance(nested_layer, QgsVectorLayer): + nested_layer.setName(str(value.get("name") or label)) + return nested_layer if nested_layer.isValid() else None + for key in ("path", "source", "file"): + path = value.get(key) + if isinstance(path, str): + layer_name = str(value.get("name") or label) + layer = QgsVectorLayer(path, layer_name, provider) + return layer if layer.isValid() else None + return None + + def _add_layers_to_project_group(self, layers: Iterable[QgsVectorLayer]) -> int: + project = self.project or QgsProject.instance() + if project is None: + return 0 + root = project.layerTreeRoot() + if root is None: + return 0 + group = root.findGroup(self.OUTPUT_GROUP_NAME) + if group is None: + group = root.insertGroup(0, self.OUTPUT_GROUP_NAME) + added = 0 + for layer in layers: + if project.mapLayer(layer.id()) is None: + project.addMapLayer(layer, False) + group.addLayer(layer) + added += 1 + return added + + def _discover_data_types(self) -> List[Datatype | str]: + members = getattr(Datatype, "__members__", None) + if isinstance(members, Mapping): + selected = [members[name] for name in self.SUPPORTED_DATA_TYPES if name in members] + if selected: + return selected + + try: + enum_values = list(Datatype) + except TypeError: + enum_values = [] + data_types: List[Datatype | str] = [] + for candidate in enum_values: + name = getattr(candidate, "name", str(candidate)).upper() + if name in self.SUPPORTED_DATA_TYPES: + data_types.append(candidate) + if data_types: + return data_types + return list(self.SUPPORTED_DATA_TYPES) + + def _format_identifier_label(self, identifier: Datatype | str) -> str: + name = identifier.name if isinstance(identifier, Datatype) else str(identifier) + parts = name.replace("_", " ").split() + return " ".join(part.capitalize() for part in parts) or "Data" + + def _default_converter_options(self) -> List[ConverterOption]: + members = getattr(SurveyName, "__members__", None) + if not isinstance(members, Mapping): + return [] + options: List[ConverterOption] = [] + for name, survey in members.items(): + label = getattr(survey, "value", None) + if not isinstance(label, str) or not label.strip(): + label = self._format_identifier_label(name) + description = getattr(survey, "description", "") + options.append(ConverterOption(identifier=name, label=label, description=description)) + return options + class ManualConversionWidget(QWidget): """Widget that lets the user map table columns to the NTGS configuration.""" @@ -132,12 +391,12 @@ def __init__( self, parent: Optional[QWidget] = None, *, - config_model: Optional[NtgsConfigurationModel] = None, + config_model: Optional[ConfigurationState] = None, project: Optional[QgsProject] = None, ): super().__init__(parent) self.project = project or QgsProject.instance() - self.model = config_model or NtgsConfigurationModel() + self.model = config_model or ConfigurationState() self.data_types = list(self.model.data_types()) if not self.data_types: raise ValueError("Configuration model does not provide any data types.") @@ -323,8 +582,8 @@ def __init__( self.tab_widget = QTabWidget() layout.addWidget(self.tab_widget) - self.automatic_widget = AutomaticConversionWidget(self, converters=converters) - self.manual_widget = ManualConversionWidget(self, config_model=NtgsConfigurationModel(), project=self.project) + self.automatic_widget = AutomaticConversionWidget(self, converters=converters, project=self.project) + self.manual_widget = ManualConversionWidget(self, config_model=ConfigurationState(), project=self.project) self.tab_widget.addTab(self.automatic_widget, "Automatic") self.tab_widget.addTab(self.manual_widget, "Manual") From cb9dfcb759adb77cbfe6498904a6135e5f89a33b Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:46:11 +0930 Subject: [PATCH 07/17] fix: update config file --- loopstructural/gui/data_conversion/configuration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/loopstructural/gui/data_conversion/configuration.py b/loopstructural/gui/data_conversion/configuration.py index f926494..0f1640f 100644 --- a/loopstructural/gui/data_conversion/configuration.py +++ b/loopstructural/gui/data_conversion/configuration.py @@ -1,4 +1,4 @@ -"""NTGS configuration helpers used by the data conversion UI.""" +"""configuration helpers used by the data conversion UI.""" from __future__ import annotations @@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, MutableMapping -class NtgsConfig: +class Config: """Container for the default NTGS configuration.""" def __init__(self) -> None: @@ -109,11 +109,11 @@ def _coerce_config_value(template_value: Any, new_value: Any) -> Any: return str(new_value) -class NtgsConfigurationModel: +class ConfigurationState: """State holder for the NTGS configuration mapping.""" def __init__(self, *, base_config: MutableMapping[str, Dict[str, Any]] | None = None): - self._config = deepcopy(base_config) if base_config is not None else NtgsConfig().as_dict() + self._config = deepcopy(base_config) if base_config is not None else Config().as_dict() def data_types(self) -> Iterable[str]: """Return the supported data types.""" From 1da46300b324ad0a4470fe1ace235cad3690de3b Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:46:34 +0930 Subject: [PATCH 08/17] refactor: renamed layers --- .../data_conversion/data_conversion_widget.py | 168 +++++++++++++++--- 1 file changed, 144 insertions(+), 24 deletions(-) diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index 77a8871..b857190 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -22,26 +22,31 @@ from qgis.core import QgsMapLayer, QgsMapLayerProxyModel, QgsProject, QgsVectorLayer from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox -from ...main.vectorLayerWrapper import QgsLayerFromGeoDataFrame, qgsLayerToGeoDataFrame +from ...main.vectorLayerWrapper import QgsLayerFromDataFrame, QgsLayerFromGeoDataFrame from .configuration import ConfigurationState from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName -try: # geopandas is required for dataframe conversions, but guard import for safety - from geopandas import GeoDataFrame as _GeoDataFrameType -except Exception: # pragma: no cover - geopandas unavailable - _GeoDataFrameType = None +try: + from geopandas import GeoDataFrame +except Exception: # pragma: no cover - geopandas may be unavailable in tests + GeoDataFrame = None + +try: + from pandas import DataFrame +except Exception: # pragma: no cover - pandas may be unavailable in tests + DataFrame = None def _run_loop_conversion( - survey_name: SurveyName, data_sources: Mapping[Datatype | str, Any] -) -> Any: + survey_name: SurveyName, data_sources: Mapping[Datatype | str, str] +) -> Tuple[Any, Any]: """Execute the LoopDataConverter workflow for the supplied survey and data.""" if not data_sources: raise ValueError("At least one data source is required for conversion.") - formatted_sources: Dict[str, Any] = {} + formatted_sources: Dict[str, str] = {} for data_type, dataset in data_sources.items(): - if dataset is None: + if not dataset: continue key = data_type.name if isinstance(data_type, Datatype) else str(data_type) formatted_sources[key.split(".")[-1].upper()] = dataset @@ -51,7 +56,8 @@ def _run_loop_conversion( input_data = InputData(**formatted_sources) converter = LoopConverter(survey_name=survey_name, data=input_data) - return converter.convert() + conversion_result = converter.convert() + return converter, conversion_result @dataclass @@ -102,6 +108,7 @@ class AutomaticConversionWidget(QWidget): """Widget showing the automatic conversion workflow.""" SUPPORTED_DATA_TYPES: Tuple[str, ...] = ("GEOLOGY", "STRUCTURE", "FAULT", "FOLD") + OUTPUT_DATA_TYPES: Tuple[str, ...] = ("GEOLOGY", "STRUCTURE", "FAULT", "FOLD", "FAULT_ORIENTATION") OUTPUT_GROUP_NAME = "Loop-Ready Data" def __init__( @@ -210,19 +217,19 @@ def _build_data_source_inputs(self) -> None: self.sources_layout.addRow(self._format_identifier_label(data_type), combo) self.layer_selectors[data_type] = combo - def _collect_data_sources(self) -> Dict[Datatype | str, Any]: - data_sources: Dict[Datatype | str, Any] = {} + def _collect_data_sources(self) -> Dict[Datatype | str, str]: + data_sources: Dict[Datatype | str, str] = {} for data_type, combo in self.layer_selectors.items(): layer = combo.currentLayer() if layer and isinstance(layer, QgsVectorLayer) and layer.isValid(): - dataframe = qgsLayerToGeoDataFrame(layer) - if dataframe is not None: - data_sources[data_type] = dataframe + path = layer.source() + if path: + data_sources[data_type] = path return data_sources def _handle_run_conversion(self) -> None: - converter = self.current_converter() - if converter is None: + converter_option = self.current_converter() + if converter_option is None: self._update_status("Please select a converter before running.", error=True) return @@ -231,12 +238,15 @@ def _handle_run_conversion(self) -> None: self._update_status("Select at least one data source layer before running.", error=True) return + loop_converter: Any = None result: Any = None added_layers = 0 try: - survey = self._normalise_survey_name(converter.identifier) - result = self.run_conversion(survey, sources) - layers = self._materialise_layers_from_result(result) + survey = self._normalise_survey_name(converter_option.identifier) + loop_converter, result = self.run_conversion(survey, sources) + layers = self._build_layers_from_converter(loop_converter) + if not layers: + layers = self._materialise_layers_from_result(result) if layers: added_layers = self._add_layers_to_project_group(layers) except Exception as exc: # pragma: no cover - UI feedback @@ -258,7 +268,9 @@ def _update_status(self, message: str, *, error: bool = False) -> None: self.status_label.setStyleSheet(f"color: {color};") self.status_label.setText(message) - def run_conversion(self, survey_name: SurveyName | str, data_sources: Mapping[Datatype | str, Any]) -> Any: + def run_conversion( + self, survey_name: SurveyName | str, data_sources: Mapping[Datatype | str, str] + ) -> Tuple[Any, Any]: """Execute the LoopConverter against the supplied dataset.""" survey = self._normalise_survey_name(survey_name) return _run_loop_conversion(survey, data_sources) @@ -280,6 +292,73 @@ def _normalise_survey_name(value: SurveyName | str) -> SurveyName: except KeyError as exc: raise ValueError(f"Unknown survey name '{value}'.") from exc + def _build_layers_from_converter(self, converter: Any) -> List[QgsVectorLayer]: + if converter is None: + return [] + data_store = getattr(converter, "data", None) + if not data_store: + return [] + + layers: List[QgsVectorLayer] = [] + for identifier in self.OUTPUT_DATA_TYPES: + dtype = self._datatype_for_name(identifier) + dataset = self._extract_dataset(data_store, dtype) + if dataset is None: + continue + layer = self._vector_layer_from_value(dtype, dataset) + if layer is not None and layer.isValid(): + layers.append(layer) + return layers + + def _datatype_for_name(self, identifier: str | Datatype) -> Datatype | str: + if isinstance(identifier, Datatype): + return identifier + try: + return Datatype[str(identifier)] + except Exception: + return str(identifier) + + def _extract_dataset(self, data_store: Any, key: Datatype | str) -> Any: + if data_store is None: + return None + + candidates: List[Any] = [] + if isinstance(key, Datatype): + candidates.append(key) + candidates.append(key.name) + value = getattr(key, "value", None) + if value is not None: + candidates.append(value) + else: + candidates.append(key) + + text_key = str(key) + if "." in text_key: + text_key = text_key.split(".", 1)[-1] + candidates.extend( + [ + text_key, + text_key.upper(), + text_key.lower(), + ] + ) + + for candidate in candidates: + if candidate is None: + continue + if isinstance(data_store, Mapping) and candidate in data_store: + return data_store[candidate] + getter = getattr(data_store, "__getitem__", None) + if callable(getter): + try: + return getter(candidate) + except Exception: + pass + attr_name = str(candidate).lower() + if hasattr(data_store, attr_name): + return getattr(data_store, attr_name) + return None + def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]: layers: List[QgsVectorLayer] = [] self._collect_layers_from_result(result, layers, prefix="output") @@ -308,8 +387,26 @@ def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorL if isinstance(value, QgsVectorLayer): value.setName(label) return value - if _GeoDataFrameType is not None and isinstance(value, _GeoDataFrameType): - return QgsLayerFromGeoDataFrame(value, layer_name=label) + if GeoDataFrame is not None and isinstance(value, GeoDataFrame): + try: + return QgsLayerFromGeoDataFrame(value, layer_name=label) + except ValueError: + # fall back to attribute-only table if geometry is missing + return QgsLayerFromDataFrame(value, layer_name=label) + if DataFrame is not None and isinstance(value, DataFrame): + return QgsLayerFromDataFrame(value, layer_name=label) + if ( + DataFrame is not None + and isinstance(value, list) + and value + and all(isinstance(item, Mapping) for item in value) + ): + try: + dataframe = DataFrame(value) + except Exception: + dataframe = None + if dataframe is not None: + return QgsLayerFromDataFrame(dataframe, layer_name=label) if isinstance(value, str): layer = QgsVectorLayer(value, label, "ogr") return layer if layer.isValid() else None @@ -366,7 +463,30 @@ def _discover_data_types(self) -> List[Datatype | str]: return list(self.SUPPORTED_DATA_TYPES) def _format_identifier_label(self, identifier: Datatype | str) -> str: - name = identifier.name if isinstance(identifier, Datatype) else str(identifier) + member: Optional[Datatype] = identifier if isinstance(identifier, Datatype) else None + text_value = None if isinstance(identifier, Datatype) else str(identifier).strip() + + if member is None and text_value: + # Try exact member name + candidates = getattr(Datatype, "__members__", {}) + key = text_value.split(".", 1)[-1].upper() + member = candidates.get(key) + + if member is None and text_value: + # Try matching the enum value + lowered = text_value.lower() + for enum_member in getattr(Datatype, "__members__", {}).values(): + if str(getattr(enum_member, "value", "")).lower() == lowered: + member = enum_member + break + + if member is not None: + name = member.name + else: + name = text_value or "Data" + if "." in name: + name = name.split(".")[-1] + parts = name.replace("_", " ").split() return " ".join(part.capitalize() for part in parts) or "Data" From 47ce01bebb2ec2091cb175b94f0cf30199c6f922 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:47:07 +0930 Subject: [PATCH 09/17] refactor: update geodataframe conversion --- loopstructural/main/vectorLayerWrapper.py | 188 ++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 4ec74d8..923b8ec 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -22,6 +22,7 @@ QgsProject, QgsRaster, QgsRasterLayer, + QgsVectorLayer, QgsWkbTypes, ) from qgis.PyQt.QtCore import QDateTime, QVariant @@ -487,6 +488,193 @@ def _fields_from_dataframe(df, drop_cols=None) -> QgsFields: return fields +def _geometry_from_value(value): + """Convert shapely/QGIS geometry objects into QgsGeometry instances.""" + if value is None: + return None + if isinstance(value, QgsGeometry): + return QgsGeometry(value) + # QgsGeometry with asWkb + for attr in ("asWkb", "exportToWkb"): + method = getattr(value, attr, None) + if callable(method): + try: + data = method() + except Exception: + data = None + if data: + try: + data = bytes(data) + except Exception: + pass + try: + return QgsGeometry.fromWkb(data) + except Exception: + continue + # Shapely geometries expose wkb/wkt attributes + wkb_data = getattr(value, "wkb", None) + if wkb_data is not None: + try: + return QgsGeometry.fromWkb(bytes(wkb_data)) + except Exception: + pass + wkt_data = getattr(value, "wkt", None) + if wkt_data: + try: + return QgsGeometry.fromWkt(str(wkt_data)) + except Exception: + pass + return None + + +def _infer_wkb_type_from_geoms(geoms) -> QgsWkbTypes.Type: + """Infer a WKB type from a GeoSeries or iterable of geometries.""" + for geom in geoms: + qgs_geom = _geometry_from_value(geom) + if qgs_geom is not None and not qgs_geom.isEmpty(): + return qgs_geom.wkbType() + return QgsWkbTypes.Point + + +def _crs_from_geodataframe_crs(crs_info) -> QgsCoordinateReferenceSystem: + """Best-effort conversion of GeoPandas CRS metadata to QgsCoordinateReferenceSystem.""" + crs = QgsCoordinateReferenceSystem() + if crs_info is None: + return crs + # pyproj CRS exposes helpers like to_wkt/to_epsg + text = None + for attr in ("to_wkt", "to_string"): + method = getattr(crs_info, attr, None) + if callable(method): + try: + text = method() + except Exception: + text = None + if text: + break + if text: + try: + return QgsCoordinateReferenceSystem.fromWkt(text) + except Exception: + temp = QgsCoordinateReferenceSystem() + if hasattr(temp, "createFromWkt"): + try: + if temp.createFromWkt(text): + return temp + except Exception: + pass + try: + epsg = crs_info.to_epsg() + if epsg: + return QgsCoordinateReferenceSystem.fromEpsgId(int(epsg)) + except Exception: + pass + if isinstance(crs_info, str): + try: + temp = QgsCoordinateReferenceSystem(crs_info) + if temp.isValid(): + return temp + except Exception: + pass + return crs + + +def QgsLayerFromGeoDataFrame(geodataframe, layer_name: str = "Converted Data"): + """Create an in-memory QgsVectorLayer from a GeoPandas GeoDataFrame.""" + if geodataframe is None: + return None + geometry_series = getattr(geodataframe, "geometry", None) + if geometry_series is None: + raise ValueError("GeoDataFrame must include a geometry column.") + geometry_column = geometry_series.name + wkb_type = _infer_wkb_type_from_geoms(geometry_series) + geom_name = QgsWkbTypes.displayString(wkb_type) or "Point" + crs = _crs_from_geodataframe_crs(getattr(geodataframe, "crs", None)) + uri = geom_name + if crs.isValid(): + authid = crs.authid() + if authid: + uri = f"{geom_name}?crs={authid}" + layer = QgsVectorLayer(uri, layer_name, "memory") + if crs.isValid(): + layer.setCrs(crs) + provider = layer.dataProvider() + attribute_fields = [] + for column in geodataframe.columns: + if column == geometry_column: + continue + attribute_fields.append(QgsField(str(column), _qvariant_type_from_dtype(geodataframe[column].dtype))) + if attribute_fields: + provider.addAttributes(attribute_fields) + layer.updateFields() + non_geom_cols = [col for col in geodataframe.columns if col != geometry_column] + features = [] + for _, row in geodataframe.iterrows(): + feat = QgsFeature(layer.fields()) + attrs = [] + for column in non_geom_cols: + val = row[column] + if isinstance(val, np.generic): + try: + val = val.item() + except Exception: + pass + if pd.isna(val): + val = None + attrs.append(val) + feat.setAttributes(attrs) + geom = _geometry_from_value(row[geometry_column]) + if geom is not None and not geom.isEmpty(): + feat.setGeometry(geom) + features.append(feat) + if features: + provider.addFeatures(features) + layer.updateExtents() + return layer + + +def QgsLayerFromDataFrame(dataframe, layer_name: str = "Converted Table"): + """Create an attribute-only memory layer from a pandas-compatible DataFrame.""" + if dataframe is None: + return None + df = dataframe.copy() + geometry_series = getattr(df, "geometry", None) + geometry_name = getattr(geometry_series, "name", None) + if geometry_name and geometry_name in df.columns: + df = df.drop(columns=[geometry_name]) + + layer = QgsVectorLayer("None", layer_name, "memory") + provider = layer.dataProvider() + + attributes = [] + for column in df.columns: + attributes.append(QgsField(str(column), _qvariant_type_from_dtype(df[column].dtype))) + if attributes: + provider.addAttributes(attributes) + layer.updateFields() + + features = [] + for _, row in df.iterrows(): + feat = QgsFeature(layer.fields()) + attrs = [] + for column in df.columns: + val = row[column] + if pd.isna(val): + val = None + elif isinstance(val, np.generic): + try: + val = val.item() + except Exception: + pass + attrs.append(val) + feat.setAttributes(attrs) + features.append(feat) + if features: + provider.addFeatures(features) + layer.updateExtents() + return layer + + # ---------- main function you'll call inside processAlgorithm ---------- From 076a08caae8aeca687ac8c9854ad394dca256117 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:57:15 +0930 Subject: [PATCH 10/17] feat: add run functionality to manual conversion --- .../data_conversion/data_conversion_widget.py | 271 +++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index b857190..7e7f05b 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -503,10 +503,12 @@ def _default_converter_options(self) -> List[ConverterOption]: options.append(ConverterOption(identifier=name, label=label, description=description)) return options - class ManualConversionWidget(QWidget): """Widget that lets the user map table columns to the NTGS configuration.""" + OUTPUT_DATA_TYPES: Tuple[str, ...] = AutomaticConversionWidget.OUTPUT_DATA_TYPES + OUTPUT_GROUP_NAME = AutomaticConversionWidget.OUTPUT_GROUP_NAME + def __init__( self, parent: Optional[QWidget] = None, @@ -554,6 +556,21 @@ def __init__( self.form_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop) self.scroll_area.setWidget(self.form_widget) + actions_widget = QWidget() + actions_layout = QHBoxLayout(actions_widget) + actions_layout.setContentsMargins(0, 0, 0, 0) + + self.run_button = QPushButton("Run Conversion") + self.run_button.clicked.connect(self._handle_run_conversion) + actions_layout.addWidget(self.run_button) + actions_layout.addStretch() + + self.status_label = QLabel("") + self.status_label.setObjectName("manualConversionStatus") + actions_layout.addWidget(self.status_label) + + layout.addWidget(actions_widget) + self.data_type_combo.currentIndexChanged.connect(self._handle_data_type_changed) self.layer_combo.layerChanged.connect(self._handle_layer_changed) @@ -668,6 +685,258 @@ def _persist_current_values(self) -> None: else: self.layer_selections[self.current_data_type] = None + def _collect_data_sources(self) -> Dict[Datatype | str, str]: + data_sources: Dict[Datatype | str, str] = {} + for data_type, selection in self.layer_selections.items(): + layer = self._layer_from_selection(selection or {}) + if layer and isinstance(layer, QgsVectorLayer) and layer.isValid(): + path = layer.source() + if path: + data_sources[self._datatype_for_name(data_type)] = path + return data_sources + + def _handle_run_conversion(self) -> None: + self._persist_current_values() + sources = self._collect_data_sources() + if not sources: + self._update_status("Select at least one data source layer before running.", error=True) + return + + converter: Any = None + result: Any = None + added_layers = 0 + try: + survey = self._manual_survey_name() + converter, result = _run_loop_conversion(survey, sources) + layers = self._build_layers_from_converter(converter) + if not layers: + layers = self._materialise_layers_from_result(result) + if layers: + added_layers = self._add_layers_to_project_group(layers) + except Exception as exc: # pragma: no cover - UI feedback + self._update_status(f"Conversion failed: {exc}", error=True) + return + + if added_layers: + message = f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'." + elif result not in (None, True): + message = f"Conversion completed: {result}" + else: + message = "Conversion completed successfully." + self._update_status(message) + + def _update_status(self, message: str, *, error: bool = False) -> None: + color = "#c00000" if error else "#006400" + self.status_label.setStyleSheet(f"color: {color};") + self.status_label.setText(message) + + def _manual_survey_name(self) -> SurveyName: + options = self._default_converter_options() + identifier: SurveyName | str | None = None + for option in options: + if str(option.identifier).upper() == "NTGS": + identifier = option.identifier + break + if identifier is None and options: + identifier = options[0].identifier + if identifier is None: + members = getattr(SurveyName, "__members__", {}) or {} + if "NTGS" in members: + identifier = members["NTGS"] + elif members: + identifier = next(iter(members.values())) + else: + raise ValueError("No survey definitions available for manual conversion.") + return AutomaticConversionWidget._normalise_survey_name(identifier) + + def _default_converter_options(self) -> List[ConverterOption]: + members = getattr(SurveyName, "__members__", None) + if not isinstance(members, Mapping): + return [] + options: List[ConverterOption] = [] + for name, survey in members.items(): + label = getattr(survey, "value", None) + if not isinstance(label, str) or not label.strip(): + label = self._format_identifier_label(name) + description = getattr(survey, "description", "") + options.append(ConverterOption(identifier=name, label=label, description=description)) + return options + + def _build_layers_from_converter(self, converter: Any) -> List[QgsVectorLayer]: + if converter is None: + return [] + data_store = getattr(converter, "data", None) + if not data_store: + return [] + + layers: List[QgsVectorLayer] = [] + for identifier in self.OUTPUT_DATA_TYPES: + dtype = self._datatype_for_name(identifier) + dataset = self._extract_dataset(data_store, dtype) + if dataset is None: + continue + layer = self._vector_layer_from_value(dtype, dataset) + if layer is not None and layer.isValid(): + layers.append(layer) + return layers + + def _datatype_for_name(self, identifier: str | Datatype) -> Datatype | str: + if isinstance(identifier, Datatype): + return identifier + try: + return Datatype[str(identifier)] + except Exception: + return str(identifier) + + def _extract_dataset(self, data_store: Any, key: Datatype | str) -> Any: + if data_store is None: + return None + + candidates: List[Any] = [] + if isinstance(key, Datatype): + candidates.append(key) + candidates.append(key.name) + value = getattr(key, "value", None) + if value is not None: + candidates.append(value) + else: + candidates.append(key) + + text_key = str(key) + if "." in text_key: + text_key = text_key.split(".", 1)[-1] + candidates.extend( + [ + text_key, + text_key.upper(), + text_key.lower(), + ] + ) + + for candidate in candidates: + if candidate is None: + continue + if isinstance(data_store, Mapping) and candidate in data_store: + return data_store[candidate] + getter = getattr(data_store, "__getitem__", None) + if callable(getter): + try: + return getter(candidate) + except Exception: + pass + attr_name = str(candidate).lower() + if hasattr(data_store, attr_name): + return getattr(data_store, attr_name) + return None + + def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]: + layers: List[QgsVectorLayer] = [] + self._collect_layers_from_result(result, layers, prefix="output") + return layers + + def _collect_layers_from_result( + self, payload: Any, layers: List[QgsVectorLayer], *, prefix: str + ) -> None: + if payload is None: + return + if isinstance(payload, Mapping): + for key, value in payload.items(): + label = str(key) + self._collect_layers_from_result(value, layers, prefix=label or prefix) + return + if isinstance(payload, (list, tuple, set)): + for index, value in enumerate(payload, start=1): + self._collect_layers_from_result(value, layers, prefix=f"{prefix}_{index}") + return + layer = self._vector_layer_from_value(prefix, payload) + if layer is not None and layer.isValid(): + layers.append(layer) + + def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorLayer]: + label = self._format_identifier_label(str(name)) + if isinstance(value, QgsVectorLayer): + value.setName(label) + return value + if GeoDataFrame is not None and isinstance(value, GeoDataFrame): + try: + return QgsLayerFromGeoDataFrame(value, layer_name=label) + except ValueError: + return QgsLayerFromDataFrame(value, layer_name=label) + if DataFrame is not None and isinstance(value, DataFrame): + return QgsLayerFromDataFrame(value, layer_name=label) + if ( + DataFrame is not None + and isinstance(value, list) + and value + and all(isinstance(item, Mapping) for item in value) + ): + try: + dataframe = DataFrame(value) + except Exception: + dataframe = None + if dataframe is not None: + return QgsLayerFromDataFrame(dataframe, layer_name=label) + if isinstance(value, str): + layer = QgsVectorLayer(value, label, "ogr") + return layer if layer.isValid() else None + if isinstance(value, Mapping): + provider = str(value.get("provider") or "ogr") + nested_layer = value.get("layer") + if isinstance(nested_layer, QgsVectorLayer): + nested_layer.setName(str(value.get("name") or label)) + return nested_layer if nested_layer.isValid() else None + for key in ("path", "source", "file"): + path = value.get(key) + if isinstance(path, str): + layer_name = str(value.get("name") or label) + layer = QgsVectorLayer(path, layer_name, provider) + return layer if layer.isValid() else None + return None + + def _add_layers_to_project_group(self, layers: Iterable[QgsVectorLayer]) -> int: + project = self.project or QgsProject.instance() + if project is None: + return 0 + root = project.layerTreeRoot() + if root is None: + return 0 + group = root.findGroup(self.OUTPUT_GROUP_NAME) + if group is None: + group = root.insertGroup(0, self.OUTPUT_GROUP_NAME) + added = 0 + for layer in layers: + if project.mapLayer(layer.id()) is None: + project.addMapLayer(layer, False) + group.addLayer(layer) + added += 1 + return added + + def _format_identifier_label(self, identifier: Datatype | str) -> str: + member: Optional[Datatype] = identifier if isinstance(identifier, Datatype) else None + text_value = None if isinstance(identifier, Datatype) else str(identifier).strip() + + if member is None and text_value: + candidates = getattr(Datatype, "__members__", {}) + key = text_value.split(".", 1)[-1].upper() + member = candidates.get(key) + + if member is None and text_value: + lowered = text_value.lower() + for enum_member in getattr(Datatype, "__members__", {}).values(): + if str(getattr(enum_member, "value", "")).lower() == lowered: + member = enum_member + break + + if member is not None: + name = member.name + else: + name = text_value or "Data" + if "." in name: + name = name.split(".")[-1] + + parts = name.replace("_", " ").split() + return " ".join(part.capitalize() for part in parts) or "Data" + def get_configuration(self) -> Dict[str, Dict[str, Any]]: """Return the configuration map built from the user selections.""" self._persist_current_values() From c108f56ca08dae03f8d5f6e8e4b8763a623f728d Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 28 Jan 2026 09:22:31 +0930 Subject: [PATCH 11/17] fix: remove manual conversion code --- .../data_conversion/data_conversion_widget.py | 555 +++--------------- loopstructural/gui/loop_widget.py | 2 +- 2 files changed, 84 insertions(+), 473 deletions(-) diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index 7e7f05b..21e38ba 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -11,19 +11,15 @@ QFormLayout, QHBoxLayout, QLabel, - QLineEdit, QPushButton, - QScrollArea, - QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) -from qgis.core import QgsMapLayer, QgsMapLayerProxyModel, QgsProject, QgsVectorLayer -from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox +from qgis.core import QgsMapLayerProxyModel, QgsProject, QgsVectorLayer +from qgis.gui import QgsMapLayerComboBox from ...main.vectorLayerWrapper import QgsLayerFromDataFrame, QgsLayerFromGeoDataFrame -from .configuration import ConfigurationState from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName try: @@ -37,8 +33,70 @@ DataFrame = None +def _is_tabular_payload(value: Any) -> bool: + if GeoDataFrame is not None and isinstance(value, GeoDataFrame): + return True + if DataFrame is not None and isinstance(value, DataFrame): + return True + return False + + +def _build_input_data( + formatted_sources: Mapping[str, str], + config_map: Optional[Mapping[str, Mapping[str, Any]]], +) -> Tuple[InputData, bool]: + if config_map is None: + return InputData(**formatted_sources), False + + for key in ("config_map", "config", "configuration"): + try: + return InputData(**formatted_sources, **{key: config_map}), True + except TypeError: + continue + + return InputData(**formatted_sources), False + + +def _build_loop_converter( + survey_name: SurveyName, + input_data: InputData, + config_map: Optional[Mapping[str, Mapping[str, Any]]], +) -> Tuple[LoopConverter, bool]: + if config_map is None: + return LoopConverter(survey_name=survey_name, data=input_data), False + + for key in ("config_map", "config", "configuration"): + try: + return ( + LoopConverter(survey_name=survey_name, data=input_data, **{key: config_map}), + True, + ) + except TypeError: + continue + + converter = LoopConverter(survey_name=survey_name, data=input_data) + for method_name in ("set_config_map", "set_config", "set_configuration"): + method = getattr(converter, method_name, None) + if callable(method): + method(config_map) + return converter, True + + for attr_name in ("config_map", "config", "configuration"): + if hasattr(converter, attr_name): + try: + setattr(converter, attr_name, config_map) + return converter, True + except Exception: + continue + + return converter, False + + def _run_loop_conversion( - survey_name: SurveyName, data_sources: Mapping[Datatype | str, str] + survey_name: SurveyName, + data_sources: Mapping[Datatype | str, str], + *, + config_map: Optional[Mapping[str, Mapping[str, Any]]] = None, ) -> Tuple[Any, Any]: """Execute the LoopDataConverter workflow for the supplied survey and data.""" if not data_sources: @@ -54,8 +112,10 @@ def _run_loop_conversion( if not formatted_sources: raise ValueError("Unable to run conversion without valid data sources.") - input_data = InputData(**formatted_sources) - converter = LoopConverter(survey_name=survey_name, data=input_data) + input_data, input_has_config = _build_input_data(formatted_sources, config_map) + converter, converter_has_config = _build_loop_converter(survey_name, input_data, config_map) + if config_map is not None and not (input_has_config or converter_has_config): + raise ValueError("Conversion configuration could not be applied to the converter.") conversion_result = converter.convert() return converter, conversion_result @@ -82,6 +142,9 @@ def _normalise_converters(converters: Optional[Iterable[Any]]) -> List[Converter if not converters: return normalised + if isinstance(converters, (str, bytes)): + converters = [converters] + for raw in converters: if isinstance(raw, ConverterOption): normalised.append(raw) @@ -170,7 +233,7 @@ def __init__( def set_converters(self, converters: Optional[Iterable[Any]]) -> None: """Populate the dropdown with the converters supplied by the backend.""" - available = converters if converters else self._default_converter_options() + available = self._default_converter_options() if converters is None else converters self._options = _normalise_converters(available) self.converter_combo.clear() for option in self._options: @@ -369,6 +432,11 @@ def _collect_layers_from_result( ) -> None: if payload is None: return + if _is_tabular_payload(payload): + layer = self._vector_layer_from_value(prefix, payload) + if layer is not None and layer.isValid(): + layers.append(layer) + return if isinstance(payload, Mapping): for key, value in payload.items(): label = str(key) @@ -503,453 +571,9 @@ def _default_converter_options(self) -> List[ConverterOption]: options.append(ConverterOption(identifier=name, label=label, description=description)) return options -class ManualConversionWidget(QWidget): - """Widget that lets the user map table columns to the NTGS configuration.""" - - OUTPUT_DATA_TYPES: Tuple[str, ...] = AutomaticConversionWidget.OUTPUT_DATA_TYPES - OUTPUT_GROUP_NAME = AutomaticConversionWidget.OUTPUT_GROUP_NAME - - def __init__( - self, - parent: Optional[QWidget] = None, - *, - config_model: Optional[ConfigurationState] = None, - project: Optional[QgsProject] = None, - ): - super().__init__(parent) - self.project = project or QgsProject.instance() - self.model = config_model or ConfigurationState() - self.data_types = list(self.model.data_types()) - if not self.data_types: - raise ValueError("Configuration model does not provide any data types.") - self.current_data_type = self.data_types[0] - self.layer_selections: Dict[str, Optional[Dict[str, str]]] = { - dtype: None for dtype in self.data_types - } - self.field_widgets: Dict[str, QWidget] = {} - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - instructions = QLabel( - "Manually map QGIS table columns to the fields expected by the NTGS configuration." - " Select a data type, choose the source table and assign each field." - ) - instructions.setWordWrap(True) - layout.addWidget(instructions) - - self.data_type_combo = QComboBox() - for dtype in self.data_types: - self.data_type_combo.addItem(dtype.title(), dtype) - layout.addWidget(self.data_type_combo) - - self.layer_combo = QgsMapLayerComboBox() - self.layer_combo.setFilters(QgsMapLayerProxyModel.VectorLayer) - self.layer_combo.setAllowEmptyLayer(True) - layout.addWidget(self.layer_combo) - - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - layout.addWidget(self.scroll_area, stretch=1) - - self.form_widget = QWidget() - self.form_layout = QFormLayout(self.form_widget) - self.form_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop) - self.scroll_area.setWidget(self.form_widget) - - actions_widget = QWidget() - actions_layout = QHBoxLayout(actions_widget) - actions_layout.setContentsMargins(0, 0, 0, 0) - - self.run_button = QPushButton("Run Conversion") - self.run_button.clicked.connect(self._handle_run_conversion) - actions_layout.addWidget(self.run_button) - actions_layout.addStretch() - - self.status_label = QLabel("") - self.status_label.setObjectName("manualConversionStatus") - actions_layout.addWidget(self.status_label) - - layout.addWidget(actions_widget) - - self.data_type_combo.currentIndexChanged.connect(self._handle_data_type_changed) - self.layer_combo.layerChanged.connect(self._handle_layer_changed) - - self._apply_layer_selection_for_current_type() - self._build_form() - - def _handle_data_type_changed(self, index: int) -> None: - next_data_type = self.data_type_combo.itemData(index) - if not next_data_type or next_data_type == self.current_data_type: - return - self._persist_current_values() - self.current_data_type = next_data_type - self._apply_layer_selection_for_current_type() - self._build_form() - - def _apply_layer_selection_for_current_type(self) -> None: - selection = self.layer_selections.get(self.current_data_type) - if not selection: - self.layer_combo.setCurrentIndex(-1) - return - layer = self._layer_from_selection(selection) - if layer is None: - self.layer_combo.setCurrentIndex(-1) - return - self.layer_combo.setLayer(layer) - - def _layer_from_selection(self, selection: Dict[str, str]) -> Optional[QgsMapLayer]: - if not selection: - return None - layer_id = selection.get("layer_id") - if not layer_id or self.project is None: - return None - return self.project.mapLayer(layer_id) - - def _handle_layer_changed(self, layer: Optional[QgsMapLayer]) -> None: - if layer and isinstance(layer, QgsVectorLayer): - self.layer_selections[self.current_data_type] = { - "layer_id": layer.id(), - "layer_name": layer.name(), - } - else: - self.layer_selections[self.current_data_type] = None - - for key, widget in self.field_widgets.items(): - if isinstance(widget, QgsFieldComboBox): - widget.setLayer(layer) - stored_value = self.model.get_value(self.current_data_type, key) - if stored_value: - widget.setField(stored_value) - else: - widget.setCurrentIndex(-1) - - def _build_form(self) -> None: - while self.form_layout.count(): - item = self.form_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - self.field_widgets.clear() - - config = self.model.get_config_for_type(self.current_data_type) - for key, default_value in config.items(): - widget = self._create_widget_for_entry(key, default_value) - self.form_layout.addRow(self._format_label(key), widget) - self.field_widgets[key] = widget - - def _create_widget_for_entry(self, key: str, value: Any) -> QWidget: - if key.endswith("_column"): - field_combo = QgsFieldComboBox() - field_combo.setLayer(self.layer_combo.currentLayer()) - if hasattr(field_combo, "setAllowEmptyFieldName"): - field_combo.setAllowEmptyFieldName(True) - stored = self.model.get_value(self.current_data_type, key) - if stored: - field_combo.setField(stored) - return field_combo - - line_edit = QLineEdit() - line_edit.setText(self._stringify_value(value)) - line_edit.setClearButtonEnabled(True) - return line_edit - - def _format_label(self, key: str) -> str: - parts = key.replace("_", " ").split() - return " ".join(part.capitalize() for part in parts) - - def _stringify_value(self, value: Any) -> str: - if isinstance(value, list): - return ", ".join(str(item) for item in value) - if value is None: - return "" - return str(value) - - def _persist_current_values(self) -> None: - if not self.field_widgets: - return - - updates: Dict[str, Any] = {} - for key, widget in self.field_widgets.items(): - if isinstance(widget, QgsFieldComboBox): - updates[key] = widget.currentField() or "" - elif isinstance(widget, QLineEdit): - updates[key] = widget.text() - self.model.update_values(self.current_data_type, updates) - - layer = self.layer_combo.currentLayer() - if layer and isinstance(layer, QgsVectorLayer): - self.layer_selections[self.current_data_type] = { - "layer_id": layer.id(), - "layer_name": layer.name(), - } - else: - self.layer_selections[self.current_data_type] = None - - def _collect_data_sources(self) -> Dict[Datatype | str, str]: - data_sources: Dict[Datatype | str, str] = {} - for data_type, selection in self.layer_selections.items(): - layer = self._layer_from_selection(selection or {}) - if layer and isinstance(layer, QgsVectorLayer) and layer.isValid(): - path = layer.source() - if path: - data_sources[self._datatype_for_name(data_type)] = path - return data_sources - - def _handle_run_conversion(self) -> None: - self._persist_current_values() - sources = self._collect_data_sources() - if not sources: - self._update_status("Select at least one data source layer before running.", error=True) - return - - converter: Any = None - result: Any = None - added_layers = 0 - try: - survey = self._manual_survey_name() - converter, result = _run_loop_conversion(survey, sources) - layers = self._build_layers_from_converter(converter) - if not layers: - layers = self._materialise_layers_from_result(result) - if layers: - added_layers = self._add_layers_to_project_group(layers) - except Exception as exc: # pragma: no cover - UI feedback - self._update_status(f"Conversion failed: {exc}", error=True) - return - - if added_layers: - message = f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'." - elif result not in (None, True): - message = f"Conversion completed: {result}" - else: - message = "Conversion completed successfully." - self._update_status(message) - - def _update_status(self, message: str, *, error: bool = False) -> None: - color = "#c00000" if error else "#006400" - self.status_label.setStyleSheet(f"color: {color};") - self.status_label.setText(message) - - def _manual_survey_name(self) -> SurveyName: - options = self._default_converter_options() - identifier: SurveyName | str | None = None - for option in options: - if str(option.identifier).upper() == "NTGS": - identifier = option.identifier - break - if identifier is None and options: - identifier = options[0].identifier - if identifier is None: - members = getattr(SurveyName, "__members__", {}) or {} - if "NTGS" in members: - identifier = members["NTGS"] - elif members: - identifier = next(iter(members.values())) - else: - raise ValueError("No survey definitions available for manual conversion.") - return AutomaticConversionWidget._normalise_survey_name(identifier) - - def _default_converter_options(self) -> List[ConverterOption]: - members = getattr(SurveyName, "__members__", None) - if not isinstance(members, Mapping): - return [] - options: List[ConverterOption] = [] - for name, survey in members.items(): - label = getattr(survey, "value", None) - if not isinstance(label, str) or not label.strip(): - label = self._format_identifier_label(name) - description = getattr(survey, "description", "") - options.append(ConverterOption(identifier=name, label=label, description=description)) - return options - - def _build_layers_from_converter(self, converter: Any) -> List[QgsVectorLayer]: - if converter is None: - return [] - data_store = getattr(converter, "data", None) - if not data_store: - return [] - - layers: List[QgsVectorLayer] = [] - for identifier in self.OUTPUT_DATA_TYPES: - dtype = self._datatype_for_name(identifier) - dataset = self._extract_dataset(data_store, dtype) - if dataset is None: - continue - layer = self._vector_layer_from_value(dtype, dataset) - if layer is not None and layer.isValid(): - layers.append(layer) - return layers - - def _datatype_for_name(self, identifier: str | Datatype) -> Datatype | str: - if isinstance(identifier, Datatype): - return identifier - try: - return Datatype[str(identifier)] - except Exception: - return str(identifier) - - def _extract_dataset(self, data_store: Any, key: Datatype | str) -> Any: - if data_store is None: - return None - - candidates: List[Any] = [] - if isinstance(key, Datatype): - candidates.append(key) - candidates.append(key.name) - value = getattr(key, "value", None) - if value is not None: - candidates.append(value) - else: - candidates.append(key) - - text_key = str(key) - if "." in text_key: - text_key = text_key.split(".", 1)[-1] - candidates.extend( - [ - text_key, - text_key.upper(), - text_key.lower(), - ] - ) - - for candidate in candidates: - if candidate is None: - continue - if isinstance(data_store, Mapping) and candidate in data_store: - return data_store[candidate] - getter = getattr(data_store, "__getitem__", None) - if callable(getter): - try: - return getter(candidate) - except Exception: - pass - attr_name = str(candidate).lower() - if hasattr(data_store, attr_name): - return getattr(data_store, attr_name) - return None - - def _materialise_layers_from_result(self, result: Any) -> List[QgsVectorLayer]: - layers: List[QgsVectorLayer] = [] - self._collect_layers_from_result(result, layers, prefix="output") - return layers - - def _collect_layers_from_result( - self, payload: Any, layers: List[QgsVectorLayer], *, prefix: str - ) -> None: - if payload is None: - return - if isinstance(payload, Mapping): - for key, value in payload.items(): - label = str(key) - self._collect_layers_from_result(value, layers, prefix=label or prefix) - return - if isinstance(payload, (list, tuple, set)): - for index, value in enumerate(payload, start=1): - self._collect_layers_from_result(value, layers, prefix=f"{prefix}_{index}") - return - layer = self._vector_layer_from_value(prefix, payload) - if layer is not None and layer.isValid(): - layers.append(layer) - - def _vector_layer_from_value(self, name: Any, value: Any) -> Optional[QgsVectorLayer]: - label = self._format_identifier_label(str(name)) - if isinstance(value, QgsVectorLayer): - value.setName(label) - return value - if GeoDataFrame is not None and isinstance(value, GeoDataFrame): - try: - return QgsLayerFromGeoDataFrame(value, layer_name=label) - except ValueError: - return QgsLayerFromDataFrame(value, layer_name=label) - if DataFrame is not None and isinstance(value, DataFrame): - return QgsLayerFromDataFrame(value, layer_name=label) - if ( - DataFrame is not None - and isinstance(value, list) - and value - and all(isinstance(item, Mapping) for item in value) - ): - try: - dataframe = DataFrame(value) - except Exception: - dataframe = None - if dataframe is not None: - return QgsLayerFromDataFrame(dataframe, layer_name=label) - if isinstance(value, str): - layer = QgsVectorLayer(value, label, "ogr") - return layer if layer.isValid() else None - if isinstance(value, Mapping): - provider = str(value.get("provider") or "ogr") - nested_layer = value.get("layer") - if isinstance(nested_layer, QgsVectorLayer): - nested_layer.setName(str(value.get("name") or label)) - return nested_layer if nested_layer.isValid() else None - for key in ("path", "source", "file"): - path = value.get(key) - if isinstance(path, str): - layer_name = str(value.get("name") or label) - layer = QgsVectorLayer(path, layer_name, provider) - return layer if layer.isValid() else None - return None - - def _add_layers_to_project_group(self, layers: Iterable[QgsVectorLayer]) -> int: - project = self.project or QgsProject.instance() - if project is None: - return 0 - root = project.layerTreeRoot() - if root is None: - return 0 - group = root.findGroup(self.OUTPUT_GROUP_NAME) - if group is None: - group = root.insertGroup(0, self.OUTPUT_GROUP_NAME) - added = 0 - for layer in layers: - if project.mapLayer(layer.id()) is None: - project.addMapLayer(layer, False) - group.addLayer(layer) - added += 1 - return added - - def _format_identifier_label(self, identifier: Datatype | str) -> str: - member: Optional[Datatype] = identifier if isinstance(identifier, Datatype) else None - text_value = None if isinstance(identifier, Datatype) else str(identifier).strip() - - if member is None and text_value: - candidates = getattr(Datatype, "__members__", {}) - key = text_value.split(".", 1)[-1].upper() - member = candidates.get(key) - - if member is None and text_value: - lowered = text_value.lower() - for enum_member in getattr(Datatype, "__members__", {}).values(): - if str(getattr(enum_member, "value", "")).lower() == lowered: - member = enum_member - break - - if member is not None: - name = member.name - else: - name = text_value or "Data" - if "." in name: - name = name.split(".")[-1] - - parts = name.replace("_", " ").split() - return " ".join(part.capitalize() for part in parts) or "Data" - - def get_configuration(self) -> Dict[str, Dict[str, Any]]: - """Return the configuration map built from the user selections.""" - self._persist_current_values() - return self.model.as_dict() - - def get_layer_selections(self) -> Dict[str, Optional[Dict[str, str]]]: - """Return the per-data-type layer selections.""" - self._persist_current_values() - return {key: (value.copy() if value else None) for key, value in self.layer_selections.items()} - class DataConversionWidget(QWidget): - """High level widget that exposes tabs for automatic and manual conversion.""" + """High level widget that exposes automatic conversion.""" def __init__( self, @@ -968,14 +592,8 @@ def __init__( description.setWordWrap(True) layout.addWidget(description) - self.tab_widget = QTabWidget() - layout.addWidget(self.tab_widget) - self.automatic_widget = AutomaticConversionWidget(self, converters=converters, project=self.project) - self.manual_widget = ManualConversionWidget(self, config_model=ConfigurationState(), project=self.project) - - self.tab_widget.addTab(self.automatic_widget, "Automatic") - self.tab_widget.addTab(self.manual_widget, "Manual") + layout.addWidget(self.automatic_widget) def set_converters(self, converters: Iterable[Any]) -> None: """Update the converter options displayed in the automatic tab.""" @@ -983,15 +601,8 @@ def set_converters(self, converters: Iterable[Any]) -> None: def get_active_configuration(self) -> Dict[str, Any]: """Return a serialisable summary of the current tab selection.""" - if self.tab_widget.currentWidget() is self.automatic_widget: - converter = self.automatic_widget.current_converter() - return { - "mode": "automatic", - "converter": converter.to_dict() if converter else None, - } - + converter = self.automatic_widget.current_converter() return { - "mode": "manual", - "layers": self.manual_widget.get_layer_selections(), - "config_map": self.manual_widget.get_configuration(), + "mode": "automatic", + "converter": converter.to_dict() if converter else None, } diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index b5b9da0..c7c47fb 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -56,8 +56,8 @@ def __init__( data_manager=self.data_manager, project=self.data_manager.project if self.data_manager else None, ) - tabWidget.addTab(self.modelling_widget, "Modelling") tabWidget.addTab(self.data_conversion_widget, "Data Conversion") + tabWidget.addTab(self.modelling_widget, "Modelling") tabWidget.addTab(self.visualisation_widget, "Visualisation") def get_modelling_widget(self): From 303ca20d021cc4bb1204d0803c524d97ef400515 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 28 Jan 2026 10:44:13 +1030 Subject: [PATCH 12/17] fix: add loopdataconverter to requirements --- loopstructural/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 8ecd2f2..b582bb9 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -6,3 +6,4 @@ loopsolver geopandas numpy==1.26.4 map2loop~=3.3 +LoopDataConverter==0.3.0 \ No newline at end of file From ba3d5242200142ac3e351dc365f479b0a00d7685 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:00:53 +0930 Subject: [PATCH 13/17] fix: added data converter as dialog --- .../gui/data_conversion/__init__.py | 4 +- .../data_conversion/data_conversion_widget.py | 46 ++++++++++--------- loopstructural/gui/loop_widget.py | 10 ---- loopstructural/plugin_main.py | 18 ++++++++ 4 files changed, 45 insertions(+), 33 deletions(-) diff --git a/loopstructural/gui/data_conversion/__init__.py b/loopstructural/gui/data_conversion/__init__.py index fd12735..6449504 100644 --- a/loopstructural/gui/data_conversion/__init__.py +++ b/loopstructural/gui/data_conversion/__init__.py @@ -1,5 +1,5 @@ """Data conversion GUI components.""" -from .data_conversion_widget import DataConversionWidget +from .data_conversion_widget import AutomaticConversionDialog, AutomaticConversionWidget -__all__ = ["DataConversionWidget"] +__all__ = ["AutomaticConversionDialog", "AutomaticConversionWidget"] diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index 21e38ba..99ae93b 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -1,4 +1,4 @@ -"""Data conversion widget displayed inside the LoopStructural dock.""" +"""Data conversion widgets and dialog for LoopStructural.""" from __future__ import annotations @@ -8,6 +8,8 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QComboBox, + QDialog, + QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel, @@ -290,16 +292,16 @@ def _collect_data_sources(self) -> Dict[Datatype | str, str]: data_sources[data_type] = path return data_sources - def _handle_run_conversion(self) -> None: + def _handle_run_conversion(self) -> bool: converter_option = self.current_converter() if converter_option is None: self._update_status("Please select a converter before running.", error=True) - return + return False sources = self._collect_data_sources() if not sources: self._update_status("Select at least one data source layer before running.", error=True) - return + return False loop_converter: Any = None result: Any = None @@ -314,7 +316,7 @@ def _handle_run_conversion(self) -> None: added_layers = self._add_layers_to_project_group(layers) except Exception as exc: # pragma: no cover - UI feedback self._update_status(f"Conversion failed: {exc}", error=True) - return + return False if added_layers: message = ( @@ -325,6 +327,7 @@ def _handle_run_conversion(self) -> None: else: message = "Conversion completed successfully." self._update_status(message) + return True def _update_status(self, message: str, *, error: bool = False) -> None: color = "#c00000" if error else "#006400" @@ -572,19 +575,18 @@ def _default_converter_options(self) -> List[ConverterOption]: return options -class DataConversionWidget(QWidget): - """High level widget that exposes automatic conversion.""" +class AutomaticConversionDialog(QDialog): + """Dialog wrapper for the automatic conversion workflow.""" def __init__( self, parent: Optional[QWidget] = None, *, - data_manager: Any = None, converters: Optional[Iterable[Any]] = None, project: Optional[QgsProject] = None, ): super().__init__(parent) - self.data_manager = data_manager + self.setWindowTitle("Data Converter") self.project = project or QgsProject.instance() layout = QVBoxLayout(self) @@ -592,17 +594,19 @@ def __init__( description.setWordWrap(True) layout.addWidget(description) - self.automatic_widget = AutomaticConversionWidget(self, converters=converters, project=self.project) - layout.addWidget(self.automatic_widget) + self.widget = AutomaticConversionWidget(self, converters=converters, project=self.project) + layout.addWidget(self.widget) + self.widget.run_button.hide() - def set_converters(self, converters: Iterable[Any]) -> None: - """Update the converter options displayed in the automatic tab.""" - self.automatic_widget.set_converters(converters) + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) - def get_active_configuration(self) -> Dict[str, Any]: - """Return a serialisable summary of the current tab selection.""" - converter = self.automatic_widget.current_converter() - return { - "mode": "automatic", - "converter": converter.to_dict() if converter else None, - } + def _run_and_accept(self) -> None: + if self.widget._handle_run_conversion(): + self.accept() + + def set_converters(self, converters: Iterable[Any]) -> None: + """Update the converter options displayed in the dialog.""" + self.widget.set_converters(converters) diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index c7c47fb..b0337a7 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -7,7 +7,6 @@ from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget -from .data_conversion import DataConversionWidget from .modelling.modelling_widget import ModellingWidget from .visualisation.visualisation_widget import VisualisationWidget @@ -51,12 +50,6 @@ def __init__( self.visualisation_widget = VisualisationWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, model_manager=self.model_manager ) - self.data_conversion_widget = DataConversionWidget( - self, - data_manager=self.data_manager, - project=self.data_manager.project if self.data_manager else None, - ) - tabWidget.addTab(self.data_conversion_widget, "Data Conversion") tabWidget.addTab(self.modelling_widget, "Modelling") tabWidget.addTab(self.visualisation_widget, "Visualisation") @@ -80,6 +73,3 @@ def get_visualisation_widget(self): """ return self.visualisation_widget - def get_data_conversion_widget(self): - """Return the data conversion widget instance.""" - return self.data_conversion_widget diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 77ff779..780c987 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -140,6 +140,11 @@ def initGui(self): self.tr("LoopStructural Modelling"), self.iface.mainWindow(), ) + self.action_data_conversion = QAction( + self.tr("LoopStructural Data Conversion"), + self.iface.mainWindow(), + ) + self.action_data_conversion.triggered.connect(self.show_data_conversion_dialog) self.action_visualisation = QAction( QIcon(os.path.dirname(__file__) + "/3D_icon.png"), self.tr("LoopStructural Visualisation"), @@ -147,10 +152,12 @@ def initGui(self): ) self.toolbar.addAction(self.action_modelling) + self.toolbar.addAction(self.action_data_conversion) self.toolbar.addAction(self.action_fault_topology) # -- Menu self.iface.addPluginToMenu(__title__, self.action_settings) self.iface.addPluginToMenu(__title__, self.action_help) + self.iface.addPluginToMenu(__title__, self.action_data_conversion) self.initProcessing() # Map2Loop tool actions @@ -338,6 +345,16 @@ def show_sampler_dialog(self): ) dialog.exec_() + def show_data_conversion_dialog(self): + """Show the data conversion dialog.""" + from loopstructural.gui.data_conversion import AutomaticConversionDialog + + dialog = AutomaticConversionDialog( + self.iface.mainWindow(), + project=self.data_manager.project if self.data_manager else None, + ) + dialog.exec_() + def show_sorter_dialog(self): """Show the automatic stratigraphic sorter dialog.""" from loopstructural.gui.map2loop_tools import SorterDialog @@ -449,6 +466,7 @@ def unload(self): for attr in ( "action_help", "action_settings", + "action_data_conversion", "action_sampler", "action_sorter", "action_user_sorter", From d1afe0136be9fa26a8a612b6ab5dd96d6146a1c9 Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:38:29 +0930 Subject: [PATCH 14/17] feat: added along section thickness calc --- loopstructural/main/m2l_api.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index def891c..51d59df 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -9,7 +9,7 @@ SorterObservationProjections, SorterUseNetworkX, ) -from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint +from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint, AlongSection from osgeo import gdal from qgis.core import QgsVectorLayer @@ -395,6 +395,7 @@ def calculate_thickness( basal_contacts, sampled_contacts, structure, + cross_sections=None, calculator_type="InterpolatedStructure", dtm=None, unit_name_field="UNITNAME", @@ -419,6 +420,8 @@ def calculate_thickness( Sampled contacts point layer. structure : QgsVectorLayer or GeoDataFrame Structure point layer with orientation data. + cross_sections : QgsVectorLayer or GeoDataFrame, optional + Cross-sections line layer, by default None. calculator_type : str, optional Type of calculator ("InterpolatedStructure" or "StructuralPoint"), by default "InterpolatedStructure". dtm : QgsRasterLayer or GDAL dataset, optional @@ -455,7 +458,8 @@ def calculate_thickness( else basal_contacts_gdf ) sampled_contacts_gdf = qgsLayerToGeoDataFrame(sampled_contacts) - structure_gdf = qgsLayerToGeoDataFrame(structure) + structure_gdf = qgsLayerToGeoDataFrame(structure) + cross_sections_gdf = qgsLayerToGeoDataFrame(cross_sections) # Log parameters via DebugManager if provided if debug_manager: @@ -471,6 +475,8 @@ def calculate_thickness( "basal_contacts": basal_contacts_gdf, "sampled_contacts": sampled_contacts_gdf, "structure": structure_gdf, + "cross_sections": cross_sections_gdf, + }, ) @@ -480,6 +486,9 @@ def calculate_thickness( 'maxy': geology_gdf.total_bounds[3], 'miny': geology_gdf.total_bounds[1], } + + + # Rename unit name field if needed if unit_name_field and unit_name_field != 'UNITNAME': if unit_name_field in geology_gdf.columns: @@ -514,7 +523,7 @@ def calculate_thickness( is_strike=orientation_type == 'Strike', max_line_length=max_line_length, ) - else: # StructuralPoint + if calculator_type == "StructuralPoint": if max_line_length is None: raise ValueError("max_line_length parameter is required for StructuralPoint calculator") calculator = StructuralPoint( @@ -523,6 +532,12 @@ def calculate_thickness( is_strike=orientation_type == 'Strike', max_line_length=max_line_length, ) + if calculator_type == "AlongSection": + calculator = AlongSection( + bounding_box=bounding_box, + sections=cross_sections_gdf, + ) + if unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) units = geology_gdf.copy() From dbe347c84bfa372dd538a4f2b15c3fa1a14a439d Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:38:54 +0930 Subject: [PATCH 15/17] refactor: added along section code --- .../thickness_calculator_widget.py | 37 ++++++++++++++++++- .../thickness_calculator_widget.ui | 14 +++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index b27aa48..1434215 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -44,9 +44,10 @@ def __init__(self, parent=None, data_manager=None, debug_manager=None): self.basalContactsComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) self.sampledContactsComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) self.structureLayerComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + self.crossSectionLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) # Initialize calculator types - self.calculator_types = ["InterpolatedStructure", "StructuralPoint"] + self.calculator_types = ["InterpolatedStructure", "StructuralPoint", "AlongSection"] self.calculatorTypeComboBox.addItems(self.calculator_types) # Initialize orientation types @@ -149,6 +150,14 @@ def _guess_layers(self): if structure_layer_match: structure_layer = self.data_manager.find_layer_by_name(structure_layer_match) self.structureLayerComboBox.setLayer(structure_layer) + + # Attempt to find cross-sections layer + cross_sections_layer_names = get_layer_names(self.crossSectionLayerComboBox) + cross_sections_matcher = ColumnMatcher(cross_sections_layer_names) + cross_sections_layer_match = cross_sections_matcher.find_match('CROSS_SECTIONS') + if cross_sections_layer_match: + cross_sections_layer = self.data_manager.find_layer_by_name(cross_sections_layer_match) + self.crossSectionLayerComboBox.setLayer(cross_sections_layer) def _setup_field_combo_boxes(self): """Set up field combo boxes to link to their respective layers.""" @@ -209,7 +218,18 @@ def _on_calculator_type_changed(self): if calculator_type == "StructuralPoint": self.maxLineLengthLabel.setVisible(True) self.maxLineLengthSpinBox.setVisible(True) - else: # InterpolatedStructure + self.crossSectionLayerLabel.setVisible(False) + self.crossSectionLayerComboBox.setVisible(False) + + if calculator_type == "InterpolatedStructure": + self.maxLineLengthLabel.setVisible(False) + self.maxLineLengthSpinBox.setVisible(False) + self.crossSectionLayerLabel.setVisible(False) + self.crossSectionLayerComboBox.setVisible(False) + + if calculator_type == "AlongSection": + self.crossSectionLayerLabel.setVisible(True) + self.crossSectionLayerComboBox.setVisible(True) self.maxLineLengthLabel.setVisible(False) self.maxLineLengthSpinBox.setVisible(False) @@ -226,6 +246,7 @@ def _restore_selection(self): ('basal_contacts_layer', self.basalContactsComboBox), ('sampled_contacts_layer', self.sampledContactsComboBox), ('structure_layer', self.structureLayerComboBox), + ("cross_sections_layer", self.crossSectionLayerComboBox) ): if layer_name := settings.get(key): layer = self.data_manager.find_layer_by_name(layer_name) @@ -276,6 +297,11 @@ def _persist_selection(self): if self.structureLayerComboBox.currentLayer() else None ), + 'cross_sections_layer': ( + self.crossSectionLayerComboBox.currentLayer().name() + if self.crossSectionLayerComboBox.currentLayer() + else None + ), 'calculator_type_index': self.calculatorTypeComboBox.currentIndex(), 'orientation_type_index': self.orientationTypeComboBox.currentIndex(), 'max_line_length': self.maxLineLengthSpinBox.value(), @@ -308,6 +334,12 @@ def _run_calculator(self): if not self.structureLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a structure layer.") return False + + elif calculator_type == "AlongSection": + if not self.crossSectionLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a cross-sections layer.") + return False + # Prepare parameters params = self.get_parameters() @@ -376,6 +408,7 @@ def get_parameters(self): 'basal_contacts': self.basalContactsComboBox.currentLayer(), 'sampled_contacts': self.sampledContactsComboBox.currentLayer(), 'structure': self.structureLayerComboBox.currentLayer(), + 'cross_sections': self.crossSectionLayerComboBox.currentLayer(), 'orientation_type': self.orientationTypeComboBox.currentText(), 'unit_name_field': self.unitNameFieldComboBox.currentField(), 'dip_field': self.dipFieldComboBox.currentField(), diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui index 63d978e..405b124 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui @@ -161,6 +161,20 @@ + + + + Cross-Sections Layer: + + + + + + + true + + + From d901fe312a8bc2393ea5c583f6e0d7e24909e40f Mon Sep 17 00:00:00 2001 From: Rabii Chaarani <50892556+rabii-chaarani@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:36:51 +0930 Subject: [PATCH 16/17] feat: add layer guessing and filters to conversion widget --- .../data_conversion/data_conversion_widget.py | 158 +++++++++++++++++- 1 file changed, 156 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index 99ae93b..b538207 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -2,10 +2,12 @@ from __future__ import annotations +import os +import re from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QComboBox, QDialog, @@ -21,6 +23,7 @@ from qgis.core import QgsMapLayerProxyModel, QgsProject, QgsVectorLayer from qgis.gui import QgsMapLayerComboBox +from ...main.helpers import ColumnMatcher from ...main.vectorLayerWrapper import QgsLayerFromDataFrame, QgsLayerFromGeoDataFrame from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName @@ -214,6 +217,11 @@ def __init__( self.sources_layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignTop) layout.addWidget(self.sources_widget) self._build_data_source_inputs() + if self.project is not None: + try: + self.project.layersAdded.connect(self._guess_layers) + except Exception: + pass actions_widget = QWidget() actions_layout = QHBoxLayout(actions_widget) @@ -276,11 +284,157 @@ def _build_data_source_inputs(self) -> None: for data_type in self._data_types: combo = QgsMapLayerComboBox() - combo.setFilters(QgsMapLayerProxyModel.VectorLayer) + if self.project is not None: + try: + combo.setProject(self.project) + except Exception: + pass + combo.setFilters(self._layer_filter_for_data_type(data_type)) combo.setAllowEmptyLayer(True) combo.setObjectName(f"automaticSource_{self._format_identifier_label(data_type)}") self.sources_layout.addRow(self._format_identifier_label(data_type), combo) self.layer_selectors[data_type] = combo + self._schedule_guess_layers() + + def _schedule_guess_layers(self) -> None: + QTimer.singleShot(0, self._guess_layers) + + def _guess_layers(self) -> None: + """Attempt to auto-select layers based on common naming conventions.""" + if self.project is None and QgsProject.instance() is None: + return + + for data_type, combo in self.layer_selectors.items(): + if combo.currentLayer() is not None: + continue + candidates = self._build_layer_candidate_map(combo) + if not candidates: + continue + matcher = ColumnMatcher(list(candidates.keys())) + match = None + for target in self._target_keys_for_data_type(data_type): + match = matcher.find_match(target) + if match is not None: + break + if match is None: + match = matcher.find_match(self._format_identifier_label(data_type)) + if match is None and isinstance(data_type, Datatype): + value = getattr(data_type, "value", None) + if isinstance(value, str) and value.strip(): + match = matcher.find_match(value) + if match: + layer = candidates.get(match) + if layer is not None: + combo.setLayer(layer) + + def _build_layer_candidate_map( + self, combo: QgsMapLayerComboBox + ) -> Dict[str, QgsVectorLayer]: + candidates: Dict[str, QgsVectorLayer] = {} + for layer in self._layers_for_combo(combo): + if not isinstance(layer, QgsVectorLayer) or not layer.isValid(): + continue + for candidate in self._layer_candidates(layer): + if candidate and candidate not in candidates: + candidates[candidate] = layer + upper_candidate = candidate.upper() if isinstance(candidate, str) else None + if upper_candidate and upper_candidate not in candidates: + candidates[upper_candidate] = layer + return candidates + + def _layers_for_combo(self, combo: QgsMapLayerComboBox) -> List[QgsVectorLayer]: + layers: List[QgsVectorLayer] = [] + for index in range(combo.count()): + layer = combo.layer(index) + if isinstance(layer, QgsVectorLayer): + layers.append(layer) + if layers: + return layers + project = self.project or QgsProject.instance() + if project is None: + return layers + for layer in project.mapLayers().values(): + if isinstance(layer, QgsVectorLayer) and layer.isValid(): + layers.append(layer) + return layers + + def _layer_candidates(self, layer: QgsVectorLayer) -> List[str]: + names: List[str] = [] + if layer is None: + return names + layer_name = layer.name() + if layer_name: + names.append(layer_name) + try: + source = layer.source() + except Exception: + source = "" + names.extend(self._names_from_source(source)) + return names + + def _names_from_source(self, source: str) -> List[str]: + if not source: + return [] + + names: List[str] = [] + parts = source.split("|") + base = parts[0].strip() + if base: + names.append(base) + basename = os.path.basename(base) + if basename: + names.append(basename) + stem, _ = os.path.splitext(basename) + if stem: + names.append(stem) + + for part in parts[1:]: + key, _, value = part.partition("=") + if not value: + continue + key = key.strip().lower() + if key in ("layername", "table"): + cleaned = value.strip().strip("\"'").strip() + if cleaned: + names.append(cleaned) + + for pattern in (r"\blayername\s*=\s*([^|;]+)", r"\btable\s*=\s*([^|;]+)"): + match = re.search(pattern, source, re.IGNORECASE) + if match: + cleaned = match.group(1).strip().strip("\"'").strip() + if cleaned: + names.append(cleaned) + + return names + + def _target_key_for_data_type(self, data_type: Datatype | str) -> str: + if isinstance(data_type, Datatype): + return data_type.name + return str(data_type) + + def _target_keys_for_data_type(self, data_type: Datatype | str) -> List[str]: + key = self._target_key_for_data_type(data_type).upper() + if key == "GEOLOGY": + return ["GEOLOGY", "LITH", "LITHOLOGY", "OUTCROP"] + if key == "FOLD": + return ["FOLD", "FOLDS"] + if key == "FAULT": + return ["FAULT", "FAULTS"] + if key == "STRUCTURE": + return ["STRUCTURE", "STRUCTURES"] + return [key] + + def _layer_filter_for_data_type(self, data_type: Datatype | str) -> int: + key = self._target_key_for_data_type(data_type).upper() + if key == "GEOLOGY": + return QgsMapLayerProxyModel.PolygonLayer + if key == "FAULT": + return QgsMapLayerProxyModel.LineLayer + if key == "STRUCTURE": + return QgsMapLayerProxyModel.PointLayer + if key == "FOLD": + return QgsMapLayerProxyModel.LineLayer + return QgsMapLayerProxyModel.VectorLayer def _collect_data_sources(self) -> Dict[Datatype | str, str]: data_sources: Dict[Datatype | str, str] = {} From 2c0fe3b4bae26036d8e675e7d7ce1706c0f4931b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Fri, 30 Jan 2026 13:02:16 +1100 Subject: [PATCH 17/17] fix: applying some copilot changes --- .../gui/data_conversion/configuration.py | 2 +- .../data_conversion/data_conversion_widget.py | 34 +++++++++++++------ loopstructural/gui/loop_widget.py | 1 - .../thickness_calculator_widget.py | 17 +++++----- loopstructural/main/m2l_api.py | 13 +++---- loopstructural/main/vectorLayerWrapper.py | 13 ++++++- 6 files changed, 49 insertions(+), 31 deletions(-) diff --git a/loopstructural/gui/data_conversion/configuration.py b/loopstructural/gui/data_conversion/configuration.py index 0f1640f..12ccfef 100644 --- a/loopstructural/gui/data_conversion/configuration.py +++ b/loopstructural/gui/data_conversion/configuration.py @@ -1,4 +1,4 @@ -"""configuration helpers used by the data conversion UI.""" +"""Configuration helpers used by the data conversion UI.""" from __future__ import annotations diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index b538207..59dcbbb 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple +from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QComboBox, @@ -25,7 +26,6 @@ from ...main.helpers import ColumnMatcher from ...main.vectorLayerWrapper import QgsLayerFromDataFrame, QgsLayerFromGeoDataFrame -from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName try: from geopandas import GeoDataFrame @@ -34,7 +34,7 @@ try: from pandas import DataFrame -except Exception: # pragma: no cover - pandas may be unavailable in tests +except ImportException: # pragma: no cover - pandas may be unavailable in tests DataFrame = None @@ -165,18 +165,27 @@ def _normalise_converters(converters: Optional[Iterable[Any]]) -> List[Converter ) label = str(raw.get("label") or raw.get("name") or identifier) description = str(raw.get("description") or "") - normalised.append(ConverterOption(identifier=identifier, label=label, description=description)) + normalised.append( + ConverterOption(identifier=identifier, label=label, description=description) + ) continue text = str(raw) normalised.append(ConverterOption(identifier=text, label=text, description="")) return normalised + class AutomaticConversionWidget(QWidget): """Widget showing the automatic conversion workflow.""" SUPPORTED_DATA_TYPES: Tuple[str, ...] = ("GEOLOGY", "STRUCTURE", "FAULT", "FOLD") - OUTPUT_DATA_TYPES: Tuple[str, ...] = ("GEOLOGY", "STRUCTURE", "FAULT", "FOLD", "FAULT_ORIENTATION") + OUTPUT_DATA_TYPES: Tuple[str, ...] = ( + "GEOLOGY", + "STRUCTURE", + "FAULT", + "FOLD", + "FAULT_ORIENTATION", + ) OUTPUT_GROUP_NAME = "Loop-Ready Data" def __init__( @@ -208,7 +217,9 @@ def __init__( self.summary_text.setMinimumHeight(80) layout.addWidget(self.summary_text) - source_description = QLabel("Select the layers that correspond to each dataset required by the converter.") + source_description = QLabel( + "Select the layers that correspond to each dataset required by the converter." + ) source_description.setWordWrap(True) layout.addWidget(source_description) @@ -221,6 +232,9 @@ def __init__( try: self.project.layersAdded.connect(self._guess_layers) except Exception: + # Best-effort: if the project object does not provide a compatible + # layersAdded signal (e.g., in certain QGIS versions or test stubs), + # silently skip automatic layer guessing rather than failing the UI. pass actions_widget = QWidget() @@ -288,6 +302,8 @@ def _build_data_source_inputs(self) -> None: try: combo.setProject(self.project) except Exception: + # Some QGIS/Qt environments may not support setProject or may raise here; + # failure to bind the project is non-fatal, so we intentionally ignore pass combo.setFilters(self._layer_filter_for_data_type(data_type)) combo.setAllowEmptyLayer(True) @@ -327,9 +343,7 @@ def _guess_layers(self) -> None: if layer is not None: combo.setLayer(layer) - def _build_layer_candidate_map( - self, combo: QgsMapLayerComboBox - ) -> Dict[str, QgsVectorLayer]: + def _build_layer_candidate_map(self, combo: QgsMapLayerComboBox) -> Dict[str, QgsVectorLayer]: candidates: Dict[str, QgsVectorLayer] = {} for layer in self._layers_for_combo(combo): if not isinstance(layer, QgsVectorLayer) or not layer.isValid(): @@ -473,9 +487,7 @@ def _handle_run_conversion(self) -> bool: return False if added_layers: - message = ( - f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'." - ) + message = f"Conversion completed: {added_layers} layer(s) added to '{self.OUTPUT_GROUP_NAME}'." elif result not in (None, True): message = f"Conversion completed: {result}" else: diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index b0337a7..15864dc 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -72,4 +72,3 @@ def get_visualisation_widget(self): The visualisation widget. """ return self.visualisation_widget - diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index 1bdd947..c2aa8cb 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -1,8 +1,8 @@ """Widget for thickness calculator.""" import os -import pandas as pd +import pandas as pd from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic @@ -151,7 +151,7 @@ def _guess_layers(self): if structure_layer_match: structure_layer = self.data_manager.find_layer_by_name(structure_layer_match) self.structureLayerComboBox.setLayer(structure_layer) - + # Attempt to find cross-sections layer cross_sections_layer_names = get_layer_names(self.crossSectionLayerComboBox) cross_sections_matcher = ColumnMatcher(cross_sections_layer_names) @@ -221,14 +221,14 @@ def _on_calculator_type_changed(self): self.maxLineLengthSpinBox.setVisible(True) self.crossSectionLayerLabel.setVisible(False) self.crossSectionLayerComboBox.setVisible(False) - - if calculator_type == "InterpolatedStructure": + + elif calculator_type == "InterpolatedStructure": self.maxLineLengthLabel.setVisible(False) self.maxLineLengthSpinBox.setVisible(False) self.crossSectionLayerLabel.setVisible(False) self.crossSectionLayerComboBox.setVisible(False) - - if calculator_type == "AlongSection": + + elif calculator_type == "AlongSection": self.crossSectionLayerLabel.setVisible(True) self.crossSectionLayerComboBox.setVisible(True) self.maxLineLengthLabel.setVisible(False) @@ -247,7 +247,7 @@ def _restore_selection(self): ('basal_contacts_layer', self.basalContactsComboBox), ('sampled_contacts_layer', self.sampledContactsComboBox), ('structure_layer', self.structureLayerComboBox), - ("cross_sections_layer", self.crossSectionLayerComboBox) + ("cross_sections_layer", self.crossSectionLayerComboBox), ): if layer_name := settings.get(key): layer = self.data_manager.find_layer_by_name(layer_name) @@ -335,13 +335,12 @@ def _run_calculator(self): if not self.structureLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a structure layer.") return False - + elif calculator_type == "AlongSection": if not self.crossSectionLayerComboBox.currentLayer(): QMessageBox.warning(self, "Missing Input", "Please select a cross-sections layer.") return False - # Prepare parameters params = self.get_parameters() diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index e7f6791..f1fee23 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -8,7 +8,7 @@ SorterObservationProjections, SorterUseNetworkX, ) -from map2loop.thickness_calculator import InterpolatedStructure, StructuralPoint, AlongSection +from map2loop.thickness_calculator import AlongSection, InterpolatedStructure, StructuralPoint from osgeo import gdal from qgis.core import QgsVectorLayer @@ -457,7 +457,7 @@ def calculate_thickness( else basal_contacts_gdf ) sampled_contacts_gdf = qgsLayerToGeoDataFrame(sampled_contacts) - structure_gdf = qgsLayerToGeoDataFrame(structure) + structure_gdf = qgsLayerToGeoDataFrame(structure) cross_sections_gdf = qgsLayerToGeoDataFrame(cross_sections) # Log parameters via DebugManager if provided @@ -475,7 +475,6 @@ def calculate_thickness( "sampled_contacts": sampled_contacts_gdf, "structure": structure_gdf, "cross_sections": cross_sections_gdf, - }, ) @@ -485,9 +484,7 @@ def calculate_thickness( 'maxy': geology_gdf.total_bounds[3], 'miny': geology_gdf.total_bounds[1], } - - - + # Rename unit name field if needed if unit_name_field and unit_name_field != 'UNITNAME': if unit_name_field in geology_gdf.columns: @@ -535,8 +532,8 @@ def calculate_thickness( calculator = AlongSection( bounding_box=bounding_box, sections=cross_sections_gdf, - ) - + ) + if unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) units = geology_gdf.copy() diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index bf30e83..2798b87 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -599,10 +599,18 @@ def _geometry_from_value(value): try: data = bytes(data) except Exception: + # Best-effort conversion to bytes; if this fails, leave data as-is + pass try: return QgsGeometry.fromWkb(data) except Exception: + # Best-effort conversion to bytes; if this fails, fall back to using the + # original data and let the subsequent fromWkb call handle it. + logger.debug( + "Failed to convert WKB data to bytes in _geometry_from_value", + exc_info=True, + ) continue # Shapely geometries expose wkb/wkt attributes wkb_data = getattr(value, "wkb", None) @@ -661,6 +669,7 @@ def _crs_from_geodataframe_crs(crs_info) -> QgsCoordinateReferenceSystem: if epsg: return QgsCoordinateReferenceSystem.fromEpsgId(int(epsg)) except Exception: + logger.debug("Failed to convert EPSG code to QgsCoordinateReferenceSystem", exc_info=True) pass if isinstance(crs_info, str): try: @@ -696,7 +705,9 @@ def QgsLayerFromGeoDataFrame(geodataframe, layer_name: str = "Converted Data"): for column in geodataframe.columns: if column == geometry_column: continue - attribute_fields.append(QgsField(str(column), _qvariant_type_from_dtype(geodataframe[column].dtype))) + attribute_fields.append( + QgsField(str(column), _qvariant_type_from_dtype(geodataframe[column].dtype)) + ) if attribute_fields: provider.addAttributes(attribute_fields) layer.updateFields()