From 81ca6dd761127ab49b7ee81810c4d2532f94f56b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 07:54:51 +0000
Subject: [PATCH 1/8] Initial plan
From c6378a11103802979fdf9ff0e2e12543e298d14f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 08:00:50 +0000
Subject: [PATCH 2/8] Add model CRS storage, validation, and UI controls
Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
---
.../geological_model_tab.py | 15 +++
.../model_definition/bounding_box.py | 105 ++++++++++++++++
.../model_definition/bounding_box.ui | 53 ++++++++
loopstructural/main/data_manager.py | 119 +++++++++++++++++-
loopstructural/main/vectorLayerWrapper.py | 48 ++++++-
5 files changed, 332 insertions(+), 8 deletions(-)
diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
index 377a8d7..d865d84 100644
--- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
+++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
@@ -150,6 +150,21 @@ def initialize_model(self):
"Please set the bounding box before initializing the model.",
)
return
+
+ # Validate model CRS
+ if not self.data_manager.is_model_crs_valid():
+ crs = self.data_manager.get_model_crs()
+ if crs is None or not crs.isValid():
+ msg = "Model CRS is not set or invalid. Please select a valid projected CRS in the Model Definition tab."
+ else:
+ msg = f"Model CRS must be projected (in meters), not geographic.\nSelected CRS: {crs.description()}\n\nPlease select a valid projected CRS in the Model Definition tab."
+
+ QMessageBox.critical(
+ self,
+ "Invalid Model CRS",
+ msg,
+ )
+ return
# create progress dialog (indeterminate)
progress = QProgressDialog("Updating geological model...", "Cancel", 0, 0, self)
diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py
index 63a941c..7f7b412 100644
--- a/loopstructural/gui/modelling/model_definition/bounding_box.py
+++ b/loopstructural/gui/modelling/model_definition/bounding_box.py
@@ -2,6 +2,7 @@
import numpy as np
from PyQt5.QtWidgets import QWidget
+from qgis.core import QgsCoordinateReferenceSystem
from qgis.PyQt import uic
from loopstructural.main.data_manager import default_bounding_box
@@ -13,6 +14,8 @@ def __init__(self, parent=None, data_manager=None):
super().__init__(parent)
ui_path = os.path.join(os.path.dirname(__file__), "bounding_box.ui")
uic.loadUi(ui_path, self)
+
+ # Connect bounding box spinbox signals
self.originXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmin': x}))
self.maxXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmax': x}))
self.originYSpinBox.valueChanged.connect(lambda y: self.onChangeExtent({'ymin': y}))
@@ -21,9 +24,111 @@ def __init__(self, parent=None, data_manager=None):
self.maxZSpinBox.valueChanged.connect(lambda z: self.onChangeExtent({'zmax': z}))
self.useCurrentViewExtentButton.clicked.connect(self.useCurrentViewExtent)
self.selectFromCurrentLayerButton.clicked.connect(self.selectFromCurrentLayer)
+
+ # Connect CRS control signals
+ self.useProjectCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
+ self.useCustomCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
+ self.crsSelector.crsChanged.connect(self.onCrsChanged)
+
+ # Set up callbacks
self.data_manager.set_bounding_box_update_callback(self.set_bounding_box)
+ self.data_manager.set_model_crs_callback(self.update_crs_ui)
+
+ # Initialize CRS UI
+ self.initialize_crs_ui()
self._update_bounding_box_styles()
+ def initialize_crs_ui(self):
+ """Initialize CRS controls with current settings."""
+ # Set initial CRS selector value
+ crs = self.data_manager.get_model_crs()
+ if crs is not None and crs.isValid():
+ self.crsSelector.setCrs(crs)
+ else:
+ # Default to project CRS
+ self.crsSelector.setCrs(self.data_manager.project.crs())
+
+ # Set radio button based on use_project_crs setting
+ if self.data_manager._use_project_crs:
+ self.useProjectCrsRadioButton.setChecked(True)
+ else:
+ self.useCustomCrsRadioButton.setChecked(True)
+
+ self.validate_crs()
+
+ def onCrsSourceChanged(self):
+ """Handle change in CRS source (project vs custom)."""
+ use_project_crs = self.useProjectCrsRadioButton.isChecked()
+ self.crsSelector.setEnabled(not use_project_crs)
+
+ if use_project_crs:
+ # Use project CRS
+ success, msg = self.data_manager.set_model_crs(None, use_project_crs=True)
+ else:
+ # Use custom CRS
+ crs = self.crsSelector.crs()
+ success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.validate_crs()
+
+ def onCrsChanged(self):
+ """Handle change in custom CRS selection."""
+ if self.useCustomCrsRadioButton.isChecked():
+ crs = self.crsSelector.crs()
+ success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
+ self.validate_crs()
+
+ def update_crs_ui(self, crs, use_project_crs):
+ """Update UI when model CRS changes externally.
+
+ Parameters
+ ----------
+ crs : QgsCoordinateReferenceSystem or None
+ The new model CRS
+ use_project_crs : bool
+ Whether to use project CRS
+ """
+ # Block signals to avoid recursive updates
+ self.useProjectCrsRadioButton.blockSignals(True)
+ self.useCustomCrsRadioButton.blockSignals(True)
+ self.crsSelector.blockSignals(True)
+
+ try:
+ if use_project_crs:
+ self.useProjectCrsRadioButton.setChecked(True)
+ self.crsSelector.setEnabled(False)
+ else:
+ self.useCustomCrsRadioButton.setChecked(True)
+ self.crsSelector.setEnabled(True)
+ if crs is not None and crs.isValid():
+ self.crsSelector.setCrs(crs)
+
+ self.validate_crs()
+ finally:
+ # Unblock signals
+ self.useProjectCrsRadioButton.blockSignals(False)
+ self.useCustomCrsRadioButton.blockSignals(False)
+ self.crsSelector.blockSignals(False)
+
+ def validate_crs(self):
+ """Validate the selected CRS and update warning label."""
+ crs = self.data_manager.get_model_crs()
+
+ if crs is None or not crs.isValid():
+ self.crsWarningLabel.setText("⚠ Invalid CRS selected. Model cannot be initialized.")
+ return False
+
+ if crs.isGeographic():
+ self.crsWarningLabel.setText(
+ f"⚠ CRS must be projected (in meters), not geographic.\n"
+ f"Selected: {crs.description()}"
+ )
+ return False
+
+ # CRS is valid and projected
+ self.crsWarningLabel.setText("")
+ return True
+
def set_bounding_box(self, bounding_box):
"""Populate UI controls with values from a BoundingBox object.
diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui
index 54e6d89..67523fe 100644
--- a/loopstructural/gui/modelling/model_definition/bounding_box.ui
+++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui
@@ -162,6 +162,52 @@
+ -
+
+
+ Coordinate Reference System (CRS)
+
+
+
-
+
+
+ Use Project CRS
+
+
+ true
+
+
+
+ -
+
+
+ Use Custom CRS
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+
+
+
+ true
+
+
+ color: red; font-weight: bold;
+
+
+
+
+
+
-
@@ -185,6 +231,13 @@
+
+
+ QgsProjectionSelectionWidget
+ QWidget
+
+
+
diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py
index 64d816a..27088f8 100644
--- a/loopstructural/main/data_manager.py
+++ b/loopstructural/main/data_manager.py
@@ -2,7 +2,7 @@
from collections import defaultdict
import numpy as np
-from qgis.core import QgsPointXY, QgsProject, QgsVectorLayer
+from qgis.core import QgsCoordinateReferenceSystem, QgsPointXY, QgsProject, QgsVectorLayer
from LoopStructural import FaultTopology, StratigraphicColumn
from LoopStructural.datatypes import BoundingBox
@@ -69,6 +69,9 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None):
self.dem_callback = None
self.widget_settings = {}
self.feature_data = defaultdict(dict)
+ self._model_crs = None
+ self._use_project_crs = True
+ self.model_crs_callback = None
def onSaveProject(self):
"""Save project data."""
@@ -171,6 +174,88 @@ def get_bounding_box(self):
"""Get the current bounding box."""
return self._bounding_box
+ def set_model_crs(self, crs, use_project_crs=False):
+ """Set the model CRS.
+
+ Parameters
+ ----------
+ crs : QgsCoordinateReferenceSystem or None
+ The CRS to use for the model. If None and use_project_crs is True,
+ will use the project CRS.
+ use_project_crs : bool
+ If True, use the project CRS instead of a custom CRS.
+
+ Returns
+ -------
+ tuple
+ (success: bool, message: str)
+ """
+ self._use_project_crs = use_project_crs
+
+ if use_project_crs:
+ crs = self.project.crs()
+
+ # Validate CRS
+ if crs is None or not crs.isValid():
+ self._model_crs = None
+ msg = "Model CRS is not valid."
+ self.logger(message=msg, log_level=2)
+ if self.model_crs_callback:
+ self.model_crs_callback(self._model_crs, self._use_project_crs)
+ return False, msg
+
+ # Check if CRS is projected (not geographic)
+ if crs.isGeographic():
+ self._model_crs = None
+ msg = f"Model CRS must be projected (in meters), not geographic. Selected CRS: {crs.description()}"
+ self.logger(message=msg, log_level=2)
+ if self.model_crs_callback:
+ self.model_crs_callback(self._model_crs, self._use_project_crs)
+ return False, msg
+
+ self._model_crs = crs
+ msg = f"Model CRS set to: {crs.description()} ({crs.authid()})"
+ self.logger(message=msg, log_level=3)
+
+ if self.model_crs_callback:
+ self.model_crs_callback(self._model_crs, self._use_project_crs)
+
+ return True, msg
+
+ def get_model_crs(self):
+ """Get the model CRS.
+
+ Returns
+ -------
+ QgsCoordinateReferenceSystem or None
+ The model CRS, or None if not set.
+ """
+ if self._use_project_crs:
+ return self.project.crs()
+ return self._model_crs
+
+ def is_model_crs_valid(self):
+ """Check if the model CRS is valid and projected.
+
+ Returns
+ -------
+ bool
+ True if the model CRS is valid and projected, False otherwise.
+ """
+ crs = self.get_model_crs()
+ if crs is None or not crs.isValid():
+ return False
+ if crs.isGeographic():
+ return False
+ return True
+
+ def set_model_crs_callback(self, callback):
+ """Set the callback for when the model CRS is updated."""
+ self.model_crs_callback = callback
+ # Trigger callback with current values
+ if self.model_crs_callback:
+ self.model_crs_callback(self.get_model_crs(), self._use_project_crs)
+
def set_elevation(self, elevation):
"""Set the elevation for the model."""
self.elevation = elevation
@@ -378,15 +463,16 @@ def update_stratigraphy(self):
"""Update the foliation features in the model manager."""
print("Updating stratigraphy...")
if self._model_manager is not None:
+ model_crs = self.get_model_crs()
if self._basal_contacts is not None:
self._model_manager.update_contact_traces(
- qgsLayerToGeoDataFrame(self._basal_contacts['layer']),
+ qgsLayerToGeoDataFrame(self._basal_contacts['layer'], target_crs=model_crs),
unit_name_field=self._basal_contacts['unitname_field'],
)
if self._structural_orientations is not None:
print("Updating structural orientations...")
self._model_manager.update_structural_data(
- qgsLayerToGeoDataFrame(self._structural_orientations['layer']),
+ qgsLayerToGeoDataFrame(self._structural_orientations['layer'], target_crs=model_crs),
strike_field=self._structural_orientations['strike_field'],
dip_field=self._structural_orientations['dip_field'],
unit_name_field=self._structural_orientations['unitname_field'],
@@ -412,8 +498,9 @@ def update_faults(self):
self._fault_topology.remove_fault(fault)
self.fault_adjacency = np.zeros((len(unique_faults), len(unique_faults)), dtype=int)
if self._model_manager is not None:
+ model_crs = self.get_model_crs()
self._model_manager.update_fault_points(
- qgsLayerToGeoDataFrame(self._fault_traces['layer']),
+ qgsLayerToGeoDataFrame(self._fault_traces['layer'], target_crs=model_crs),
fault_name_field=self._fault_traces['fault_name_field'],
fault_dip_field=self._fault_traces['fault_dip_field'],
fault_pitch_field=self._fault_traces.get('fault_pitch_field', None),
@@ -476,6 +563,8 @@ def to_dict(self):
'use_dem': self.use_dem,
'elevation': self.elevation,
'widget_settings': self.widget_settings,
+ 'model_crs': self._model_crs.authid() if self._model_crs and self._model_crs.isValid() else None,
+ 'use_project_crs': self._use_project_crs,
}
def from_dict(self, data):
@@ -514,6 +603,14 @@ def from_dict(self, data):
self.stratigraphic_column_callback()
if 'widget_settings' in data:
self.widget_settings = data['widget_settings']
+
+ # Load model CRS settings
+ if 'use_project_crs' in data:
+ self._use_project_crs = data['use_project_crs']
+ if 'model_crs' in data and data['model_crs'] is not None:
+ crs = QgsCoordinateReferenceSystem(data['model_crs'])
+ if crs.isValid():
+ self.set_model_crs(crs, use_project_crs=self._use_project_crs)
def update_from_dict(self, data):
"""Update the data manager from a dictionary."""
@@ -590,6 +687,17 @@ def update_from_dict(self, data):
self.widget_settings = data['widget_settings']
else:
self.widget_settings = {}
+
+ # Load model CRS settings
+ if 'use_project_crs' in data:
+ self._use_project_crs = data['use_project_crs']
+ else:
+ self._use_project_crs = True
+
+ if 'model_crs' in data and data['model_crs'] is not None:
+ crs = QgsCoordinateReferenceSystem(data['model_crs'])
+ if crs.isValid():
+ self.set_model_crs(crs, use_project_crs=self._use_project_crs)
if self.stratigraphic_column_callback:
self.stratigraphic_column_callback()
@@ -644,9 +752,10 @@ def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=Non
if foliation_name not in self.feature_data:
raise ValueError(f"Foliation '{foliation_name}' does not exist in the data manager.")
foliation_data = self.feature_data[foliation_name]
+ model_crs = self.get_model_crs()
for layer in foliation_data.values():
layer['df'] = qgsLayerToGeoDataFrame(
- layer['layer']
+ layer['layer'], target_crs=model_crs
) # Convert QgsVectorLayer to GeoDataFrame
if self._model_manager:
self._model_manager.add_foliation(
diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py
index b4ed3e9..b723a84 100644
--- a/loopstructural/main/vectorLayerWrapper.py
+++ b/loopstructural/main/vectorLayerWrapper.py
@@ -88,25 +88,67 @@ def qgsRasterToGdalDataset(rlayer: QgsRasterLayer):
return ds
-def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame:
+def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
+ """Convert a QgsVectorLayer to a GeoDataFrame, optionally transforming to a target CRS.
+
+ Parameters
+ ----------
+ layer : QgsVectorLayer
+ The vector layer to convert
+ target_crs : QgsCoordinateReferenceSystem, optional
+ If provided, all geometries will be transformed to this CRS.
+ If None, the layer's source CRS is used.
+
+ Returns
+ -------
+ gpd.GeoDataFrame
+ GeoDataFrame with geometries in the specified CRS
+ """
if layer is None:
return None
+
features = layer.getFeatures()
fields = layer.fields()
data = {'geometry': []}
for f in fields:
data[f.name()] = []
+
+ # Set up coordinate transformation if needed
+ transform = None
+ source_crs = layer.sourceCrs()
+ output_crs = source_crs
+
+ if target_crs is not None and target_crs.isValid():
+ if source_crs.isValid() and source_crs != target_crs:
+ transform = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
+ output_crs = target_crs
+
for feature in features:
geom = feature.geometry()
if geom.isEmpty():
continue
- data['geometry'].append(geom)
+
+ # Transform geometry if needed
+ if transform is not None:
+ geom_copy = QgsGeometry(geom)
+ try:
+ geom_copy.transform(transform)
+ data['geometry'].append(geom_copy)
+ except Exception:
+ # If transformation fails, skip this feature
+ continue
+ else:
+ data['geometry'].append(geom)
+
+ # Copy field values
for f in fields:
if f.type() == QVariant.String:
data[f.name()].append(str(feature[f.name()]))
else:
data[f.name()].append(feature[f.name()])
- return gpd.GeoDataFrame(data, crs=layer.sourceCrs().authid())
+
+ return gpd.GeoDataFrame(data, crs=output_crs.authid())
+
def qgsLayerToDataFrame(src, dtm=None) -> pd.DataFrame:
From efc5c34dd0852b66bc421467a4f1e305d0d75f2d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 08:02:49 +0000
Subject: [PATCH 3/8] Fix linter issues and add CRS validation tests
Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
---
.../model_definition/bounding_box.py | 1 -
tests/qgis/test_model_crs.py | 127 ++++++++++++++++++
2 files changed, 127 insertions(+), 1 deletion(-)
create mode 100644 tests/qgis/test_model_crs.py
diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py
index 7f7b412..7971775 100644
--- a/loopstructural/gui/modelling/model_definition/bounding_box.py
+++ b/loopstructural/gui/modelling/model_definition/bounding_box.py
@@ -2,7 +2,6 @@
import numpy as np
from PyQt5.QtWidgets import QWidget
-from qgis.core import QgsCoordinateReferenceSystem
from qgis.PyQt import uic
from loopstructural.main.data_manager import default_bounding_box
diff --git a/tests/qgis/test_model_crs.py b/tests/qgis/test_model_crs.py
new file mode 100644
index 0000000..a099709
--- /dev/null
+++ b/tests/qgis/test_model_crs.py
@@ -0,0 +1,127 @@
+import unittest
+from unittest.mock import MagicMock, Mock
+
+from qgis.core import QgsCoordinateReferenceSystem, QgsProject
+
+from loopstructural.main.data_manager import ModellingDataManager
+
+
+class TestModelCRS(unittest.TestCase):
+ """Unit tests for Model CRS functionality."""
+
+ def setUp(self):
+ """Set up test fixtures."""
+ # Create mock objects
+ self.mock_project = Mock(spec=QgsProject)
+ self.mock_canvas = Mock()
+ self.mock_logger = Mock()
+
+ # Set up mock project CRS (WGS84 UTM 55S - projected)
+ self.mock_project_crs = QgsCoordinateReferenceSystem("EPSG:32755")
+ self.mock_project.crs.return_value = self.mock_project_crs
+
+ # Initialize data manager
+ self.data_manager = ModellingDataManager(
+ project=self.mock_project,
+ mapCanvas=self.mock_canvas,
+ logger=self.mock_logger
+ )
+
+ def test_initial_crs_uses_project_crs_flag(self):
+ """Test that initial state has use_project_crs=True."""
+ self.assertTrue(self.data_manager._use_project_crs)
+
+ def test_set_valid_projected_crs(self):
+ """Test setting a valid projected CRS."""
+ # Create a projected CRS (WGS84 / UTM zone 33N)
+ crs = QgsCoordinateReferenceSystem("EPSG:32633")
+
+ success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.assertTrue(success)
+ self.assertEqual(self.data_manager.get_model_crs(), crs)
+ self.assertFalse(self.data_manager._use_project_crs)
+
+ def test_reject_geographic_crs(self):
+ """Test that geographic CRS is rejected."""
+ # Create a geographic CRS (WGS84)
+ crs = QgsCoordinateReferenceSystem("EPSG:4326")
+
+ success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.assertFalse(success)
+ self.assertIn("geographic", msg.lower())
+ self.assertIsNone(self.data_manager._model_crs)
+
+ def test_reject_invalid_crs(self):
+ """Test that invalid CRS is rejected."""
+ # Create an invalid CRS
+ crs = QgsCoordinateReferenceSystem()
+
+ success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.assertFalse(success)
+ self.assertIn("not valid", msg.lower())
+ self.assertIsNone(self.data_manager._model_crs)
+
+ def test_use_project_crs(self):
+ """Test using project CRS."""
+ success, msg = self.data_manager.set_model_crs(None, use_project_crs=True)
+
+ self.assertTrue(success)
+ self.assertTrue(self.data_manager._use_project_crs)
+ # Should return project CRS when use_project_crs is True
+ self.assertEqual(self.data_manager.get_model_crs(), self.mock_project_crs)
+
+ def test_is_model_crs_valid_with_projected(self):
+ """Test CRS validation with a projected CRS."""
+ crs = QgsCoordinateReferenceSystem("EPSG:32633")
+ self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.assertTrue(self.data_manager.is_model_crs_valid())
+
+ def test_is_model_crs_valid_with_geographic(self):
+ """Test CRS validation rejects geographic CRS."""
+ crs = QgsCoordinateReferenceSystem("EPSG:4326")
+ self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ self.assertFalse(self.data_manager.is_model_crs_valid())
+
+ def test_crs_persistence_to_dict(self):
+ """Test CRS is saved in to_dict."""
+ crs = QgsCoordinateReferenceSystem("EPSG:32633")
+ self.data_manager.set_model_crs(crs, use_project_crs=False)
+
+ data_dict = self.data_manager.to_dict()
+
+ self.assertEqual(data_dict['model_crs'], 'EPSG:32633')
+ self.assertFalse(data_dict['use_project_crs'])
+
+ def test_crs_persistence_from_dict(self):
+ """Test CRS is restored from from_dict."""
+ # Create a data dict with CRS info
+ data = {
+ 'model_crs': 'EPSG:32633',
+ 'use_project_crs': False,
+ }
+
+ # Clear current CRS
+ self.data_manager._model_crs = None
+ self.data_manager._use_project_crs = True
+
+ # Load from dict (just the CRS part - simplified test)
+ if 'use_project_crs' in data:
+ self.data_manager._use_project_crs = data['use_project_crs']
+ if 'model_crs' in data and data['model_crs'] is not None:
+ crs = QgsCoordinateReferenceSystem(data['model_crs'])
+ if crs.isValid():
+ self.data_manager.set_model_crs(crs, use_project_crs=self.data_manager._use_project_crs)
+
+ # Verify
+ self.assertFalse(self.data_manager._use_project_crs)
+ restored_crs = self.data_manager.get_model_crs()
+ self.assertEqual(restored_crs.authid(), 'EPSG:32633')
+
+
+if __name__ == '__main__':
+ unittest.main()
From 30e858aa2c0d7db438f3c50a7cae3eb9a315022a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 08:04:46 +0000
Subject: [PATCH 4/8] Improve error handling and type safety in CRS
transformation
Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
---
loopstructural/main/data_manager.py | 6 +++++-
loopstructural/main/vectorLayerWrapper.py | 22 +++++++++++++++++++---
2 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py
index 27088f8..b0195ee 100644
--- a/loopstructural/main/data_manager.py
+++ b/loopstructural/main/data_manager.py
@@ -563,7 +563,11 @@ def to_dict(self):
'use_dem': self.use_dem,
'elevation': self.elevation,
'widget_settings': self.widget_settings,
- 'model_crs': self._model_crs.authid() if self._model_crs and self._model_crs.isValid() else None,
+ 'model_crs': (
+ self._model_crs.authid()
+ if self._model_crs and isinstance(self._model_crs, QgsCoordinateReferenceSystem) and self._model_crs.isValid()
+ else None
+ ),
'use_project_crs': self._use_project_crs,
}
diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py
index b723a84..b3281e0 100644
--- a/loopstructural/main/vectorLayerWrapper.py
+++ b/loopstructural/main/vectorLayerWrapper.py
@@ -1,5 +1,6 @@
import os
import tempfile
+import warnings
import geopandas as gpd
import numpy as np
@@ -132,10 +133,25 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
if transform is not None:
geom_copy = QgsGeometry(geom)
try:
- geom_copy.transform(transform)
+ result = geom_copy.transform(transform)
+ if result != 0:
+ # Transform returned error code
+ warnings.warn(
+ f"Failed to transform geometry (error code {result}). "
+ f"Source CRS: {source_crs.authid()}, Target CRS: {target_crs.authid()}. "
+ f"Skipping feature.",
+ RuntimeWarning
+ )
+ continue
data['geometry'].append(geom_copy)
- except Exception:
- # If transformation fails, skip this feature
+ except Exception as e:
+ # If transformation fails, log warning and skip this feature
+ warnings.warn(
+ f"Exception during CRS transformation: {e}. "
+ f"Source CRS: {source_crs.authid()}, Target CRS: {target_crs.authid()}. "
+ f"Skipping feature.",
+ RuntimeWarning
+ )
continue
else:
data['geometry'].append(geom)
From f715dbf475f8de73052f0780484a73dbc0f13d16 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 08:06:31 +0000
Subject: [PATCH 5/8] Add robust error handling and improve code safety
Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
---
.../model_definition/bounding_box.py | 8 ++++++-
loopstructural/main/data_manager.py | 22 ++++++++++++++-----
loopstructural/main/vectorLayerWrapper.py | 8 +++++--
3 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py
index 7971775..a192ea7 100644
--- a/loopstructural/gui/modelling/model_definition/bounding_box.py
+++ b/loopstructural/gui/modelling/model_definition/bounding_box.py
@@ -118,9 +118,15 @@ def validate_crs(self):
return False
if crs.isGeographic():
+ # Safely get CRS description
+ try:
+ crs_desc = crs.description() or crs.authid() or "Unknown"
+ except Exception:
+ crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
+
self.crsWarningLabel.setText(
f"⚠ CRS must be projected (in meters), not geographic.\n"
- f"Selected: {crs.description()}"
+ f"Selected: {crs_desc}"
)
return False
diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py
index b0195ee..ee4083f 100644
--- a/loopstructural/main/data_manager.py
+++ b/loopstructural/main/data_manager.py
@@ -524,6 +524,22 @@ def clear_data(self):
self._fault_traces = None
self._structural_orientations = None
+ def _get_model_crs_authid(self):
+ """Get the model CRS authid string for serialization.
+
+ Returns
+ -------
+ str or None
+ CRS authid string (e.g., 'EPSG:32633') or None if CRS is not valid.
+ """
+ if not self._model_crs:
+ return None
+ if not isinstance(self._model_crs, QgsCoordinateReferenceSystem):
+ return None
+ if not self._model_crs.isValid():
+ return None
+ return self._model_crs.authid()
+
def to_dict(self):
"""Convert the data manager to a dictionary."""
# Create copies of the dictionaries to avoid modifying the originals
@@ -563,11 +579,7 @@ def to_dict(self):
'use_dem': self.use_dem,
'elevation': self.elevation,
'widget_settings': self.widget_settings,
- 'model_crs': (
- self._model_crs.authid()
- if self._model_crs and isinstance(self._model_crs, QgsCoordinateReferenceSystem) and self._model_crs.isValid()
- else None
- ),
+ 'model_crs': self._get_model_crs_authid(),
'use_project_crs': self._use_project_crs,
}
diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py
index b3281e0..1c9d592 100644
--- a/loopstructural/main/vectorLayerWrapper.py
+++ b/loopstructural/main/vectorLayerWrapper.py
@@ -136,9 +136,11 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
result = geom_copy.transform(transform)
if result != 0:
# Transform returned error code
+ source_id = source_crs.authid() if source_crs and source_crs.isValid() else "Unknown"
+ target_id = target_crs.authid() if target_crs and target_crs.isValid() else "Unknown"
warnings.warn(
f"Failed to transform geometry (error code {result}). "
- f"Source CRS: {source_crs.authid()}, Target CRS: {target_crs.authid()}. "
+ f"Source CRS: {source_id}, Target CRS: {target_id}. "
f"Skipping feature.",
RuntimeWarning
)
@@ -146,9 +148,11 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
data['geometry'].append(geom_copy)
except Exception as e:
# If transformation fails, log warning and skip this feature
+ source_id = source_crs.authid() if source_crs and source_crs.isValid() else "Unknown"
+ target_id = target_crs.authid() if target_crs and target_crs.isValid() else "Unknown"
warnings.warn(
f"Exception during CRS transformation: {e}. "
- f"Source CRS: {source_crs.authid()}, Target CRS: {target_crs.authid()}. "
+ f"Source CRS: {source_id}, Target CRS: {target_id}. "
f"Skipping feature.",
RuntimeWarning
)
From 0a33a95a7670d3bd28274143afc69fa37e571619 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 08:08:31 +0000
Subject: [PATCH 6/8] Final code quality improvements and null-safe handling
Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com>
---
.../geological_model_tab.py | 7 ++++-
loopstructural/main/data_manager.py | 16 ++++++++--
loopstructural/main/vectorLayerWrapper.py | 29 +++++++++++++++----
3 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
index d865d84..7cc00a5 100644
--- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
+++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py
@@ -157,7 +157,12 @@ def initialize_model(self):
if crs is None or not crs.isValid():
msg = "Model CRS is not set or invalid. Please select a valid projected CRS in the Model Definition tab."
else:
- msg = f"Model CRS must be projected (in meters), not geographic.\nSelected CRS: {crs.description()}\n\nPlease select a valid projected CRS in the Model Definition tab."
+ # Safely get CRS description
+ try:
+ crs_desc = crs.description() or crs.authid() or "Unknown"
+ except Exception:
+ crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
+ msg = f"Model CRS must be projected (in meters), not geographic.\nSelected CRS: {crs_desc}\n\nPlease select a valid projected CRS in the Model Definition tab."
QMessageBox.critical(
self,
diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py
index ee4083f..fbd4a9b 100644
--- a/loopstructural/main/data_manager.py
+++ b/loopstructural/main/data_manager.py
@@ -207,14 +207,26 @@ def set_model_crs(self, crs, use_project_crs=False):
# Check if CRS is projected (not geographic)
if crs.isGeographic():
self._model_crs = None
- msg = f"Model CRS must be projected (in meters), not geographic. Selected CRS: {crs.description()}"
+ # Safely get CRS description
+ try:
+ crs_desc = crs.description() or crs.authid() or "Unknown"
+ except Exception:
+ crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
+ msg = f"Model CRS must be projected (in meters), not geographic. Selected CRS: {crs_desc}"
self.logger(message=msg, log_level=2)
if self.model_crs_callback:
self.model_crs_callback(self._model_crs, self._use_project_crs)
return False, msg
self._model_crs = crs
- msg = f"Model CRS set to: {crs.description()} ({crs.authid()})"
+ # Safely get CRS description
+ try:
+ crs_desc = crs.description() or "Unknown"
+ crs_id = crs.authid() or "Unknown"
+ except Exception:
+ crs_desc = "Unknown"
+ crs_id = crs.authid() if hasattr(crs, 'authid') else "Unknown"
+ msg = f"Model CRS set to: {crs_desc} ({crs_id})"
self.logger(message=msg, log_level=3)
if self.model_crs_callback:
diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py
index 1c9d592..25d7997 100644
--- a/loopstructural/main/vectorLayerWrapper.py
+++ b/loopstructural/main/vectorLayerWrapper.py
@@ -89,6 +89,27 @@ def qgsRasterToGdalDataset(rlayer: QgsRasterLayer):
return ds
+def _get_crs_id(crs):
+ """Get a safe string identifier for a CRS.
+
+ Parameters
+ ----------
+ crs : QgsCoordinateReferenceSystem or None
+ The CRS to get an ID for
+
+ Returns
+ -------
+ str
+ CRS authid or "Unknown" if unavailable
+ """
+ if crs and crs.isValid():
+ try:
+ return crs.authid() or "Unknown"
+ except Exception:
+ return "Unknown"
+ return "Unknown"
+
+
def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
"""Convert a QgsVectorLayer to a GeoDataFrame, optionally transforming to a target CRS.
@@ -136,11 +157,9 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
result = geom_copy.transform(transform)
if result != 0:
# Transform returned error code
- source_id = source_crs.authid() if source_crs and source_crs.isValid() else "Unknown"
- target_id = target_crs.authid() if target_crs and target_crs.isValid() else "Unknown"
warnings.warn(
f"Failed to transform geometry (error code {result}). "
- f"Source CRS: {source_id}, Target CRS: {target_id}. "
+ f"Source CRS: {_get_crs_id(source_crs)}, Target CRS: {_get_crs_id(target_crs)}. "
f"Skipping feature.",
RuntimeWarning
)
@@ -148,11 +167,9 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
data['geometry'].append(geom_copy)
except Exception as e:
# If transformation fails, log warning and skip this feature
- source_id = source_crs.authid() if source_crs and source_crs.isValid() else "Unknown"
- target_id = target_crs.authid() if target_crs and target_crs.isValid() else "Unknown"
warnings.warn(
f"Exception during CRS transformation: {e}. "
- f"Source CRS: {source_id}, Target CRS: {target_id}. "
+ f"Source CRS: {_get_crs_id(source_crs)}, Target CRS: {_get_crs_id(target_crs)}. "
f"Skipping feature.",
RuntimeWarning
)
From d4c9878652cb59ae27c9ac64ca9d75fa0bfddbb4 Mon Sep 17 00:00:00 2001
From: Lachlan Grose
Date: Tue, 27 Jan 2026 09:55:40 +1030
Subject: [PATCH 7/8] Propagate project crs change to widget
---
.../model_definition/bounding_box.py | 66 ++++++++++++++-----
1 file changed, 49 insertions(+), 17 deletions(-)
diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py
index a192ea7..594f70e 100644
--- a/loopstructural/gui/modelling/model_definition/bounding_box.py
+++ b/loopstructural/gui/modelling/model_definition/bounding_box.py
@@ -2,6 +2,7 @@
import numpy as np
from PyQt5.QtWidgets import QWidget
+from qgis.core import QgsProject
from qgis.PyQt import uic
from loopstructural.main.data_manager import default_bounding_box
@@ -13,7 +14,7 @@ def __init__(self, parent=None, data_manager=None):
super().__init__(parent)
ui_path = os.path.join(os.path.dirname(__file__), "bounding_box.ui")
uic.loadUi(ui_path, self)
-
+
# Connect bounding box spinbox signals
self.originXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmin': x}))
self.maxXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmax': x}))
@@ -23,20 +24,28 @@ def __init__(self, parent=None, data_manager=None):
self.maxZSpinBox.valueChanged.connect(lambda z: self.onChangeExtent({'zmax': z}))
self.useCurrentViewExtentButton.clicked.connect(self.useCurrentViewExtent)
self.selectFromCurrentLayerButton.clicked.connect(self.selectFromCurrentLayer)
-
+
# Connect CRS control signals
self.useProjectCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
self.useCustomCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
self.crsSelector.crsChanged.connect(self.onCrsChanged)
-
+
# Set up callbacks
self.data_manager.set_bounding_box_update_callback(self.set_bounding_box)
self.data_manager.set_model_crs_callback(self.update_crs_ui)
-
+
# Initialize CRS UI
self.initialize_crs_ui()
self._update_bounding_box_styles()
+ # Connect to project CRS changes so the widget updates when the project's CRS changes
+ try:
+ project = getattr(self.data_manager, 'project', None) or QgsProject.instance()
+ project.crsChanged.connect(self._onProjectCrsChanged)
+ except Exception:
+ # If the signal isn't available or connection fails, ignore to keep widget functional
+ pass
+
def initialize_crs_ui(self):
"""Initialize CRS controls with current settings."""
# Set initial CRS selector value
@@ -46,20 +55,20 @@ def initialize_crs_ui(self):
else:
# Default to project CRS
self.crsSelector.setCrs(self.data_manager.project.crs())
-
+
# Set radio button based on use_project_crs setting
if self.data_manager._use_project_crs:
self.useProjectCrsRadioButton.setChecked(True)
else:
self.useCustomCrsRadioButton.setChecked(True)
-
+
self.validate_crs()
def onCrsSourceChanged(self):
"""Handle change in CRS source (project vs custom)."""
use_project_crs = self.useProjectCrsRadioButton.isChecked()
self.crsSelector.setEnabled(not use_project_crs)
-
+
if use_project_crs:
# Use project CRS
success, msg = self.data_manager.set_model_crs(None, use_project_crs=True)
@@ -67,7 +76,7 @@ def onCrsSourceChanged(self):
# Use custom CRS
crs = self.crsSelector.crs()
success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
-
+
self.validate_crs()
def onCrsChanged(self):
@@ -79,7 +88,7 @@ def onCrsChanged(self):
def update_crs_ui(self, crs, use_project_crs):
"""Update UI when model CRS changes externally.
-
+
Parameters
----------
crs : QgsCoordinateReferenceSystem or None
@@ -91,17 +100,19 @@ def update_crs_ui(self, crs, use_project_crs):
self.useProjectCrsRadioButton.blockSignals(True)
self.useCustomCrsRadioButton.blockSignals(True)
self.crsSelector.blockSignals(True)
-
+
try:
if use_project_crs:
self.useProjectCrsRadioButton.setChecked(True)
self.crsSelector.setEnabled(False)
+ self.crsSelector.setCrs(crs)
+
else:
self.useCustomCrsRadioButton.setChecked(True)
self.crsSelector.setEnabled(True)
if crs is not None and crs.isValid():
self.crsSelector.setCrs(crs)
-
+
self.validate_crs()
finally:
# Unblock signals
@@ -109,27 +120,48 @@ def update_crs_ui(self, crs, use_project_crs):
self.useCustomCrsRadioButton.blockSignals(False)
self.crsSelector.blockSignals(False)
+ def _onProjectCrsChanged(self, crs=None):
+ """Handle project CRS changes and update UI when the widget is using the project CRS.
+
+ Accept an optional `crs` argument because different QGIS versions may emit the
+ new CRS or emit no arguments when the project's CRS changes.
+ """
+ # If the signal didn't provide a CRS, try to obtain it from the project's current CRS
+ if crs is None:
+ try:
+ project = getattr(self.data_manager, 'project', None) or QgsProject.instance()
+ crs = project.crs()
+ except Exception:
+ crs = None
+
+ # Only update the UI if the model is configured to use the project CRS
+ try:
+ if getattr(self.data_manager, '_use_project_crs', False):
+ # Update the UI to reflect the new project CRS
+ self.update_crs_ui(crs, use_project_crs=True)
+ except Exception:
+ pass
+
def validate_crs(self):
"""Validate the selected CRS and update warning label."""
crs = self.data_manager.get_model_crs()
-
+
if crs is None or not crs.isValid():
self.crsWarningLabel.setText("⚠ Invalid CRS selected. Model cannot be initialized.")
return False
-
+
if crs.isGeographic():
# Safely get CRS description
try:
crs_desc = crs.description() or crs.authid() or "Unknown"
except Exception:
crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
-
+
self.crsWarningLabel.setText(
- f"⚠ CRS must be projected (in meters), not geographic.\n"
- f"Selected: {crs_desc}"
+ f"⚠ CRS must be projected (in meters), not geographic.\n" f"Selected: {crs_desc}"
)
return False
-
+
# CRS is valid and projected
self.crsWarningLabel.setText("")
return True
From e15c8300eaf18f6efe2535de0c7dc6a452887b41 Mon Sep 17 00:00:00 2001
From: Lachlan Grose
Date: Tue, 27 Jan 2026 09:55:53 +1030
Subject: [PATCH 8/8] use logging rather than warning
---
loopstructural/main/vectorLayerWrapper.py | 43 +++++++++++------------
1 file changed, 21 insertions(+), 22 deletions(-)
diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py
index 25d7997..f744d0c 100644
--- a/loopstructural/main/vectorLayerWrapper.py
+++ b/loopstructural/main/vectorLayerWrapper.py
@@ -1,6 +1,7 @@
+import logging
import os
import tempfile
-import warnings
+from typing import Optional
import geopandas as gpd
import numpy as np
@@ -28,6 +29,8 @@
from qgis.PyQt.QtCore import QDateTime, QVariant
from shapely.geometry import LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon
+logger = logging.getLogger(__name__)
+
def qgsRasterToGdalDataset(rlayer: QgsRasterLayer):
"""
@@ -91,12 +94,12 @@ def qgsRasterToGdalDataset(rlayer: QgsRasterLayer):
def _get_crs_id(crs):
"""Get a safe string identifier for a CRS.
-
+
Parameters
----------
crs : QgsCoordinateReferenceSystem or None
The CRS to get an ID for
-
+
Returns
-------
str
@@ -110,9 +113,9 @@ def _get_crs_id(crs):
return "Unknown"
-def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
+def qgsLayerToGeoDataFrame(layer, target_crs=None) -> Optional[gpd.GeoDataFrame]:
"""Convert a QgsVectorLayer to a GeoDataFrame, optionally transforming to a target CRS.
-
+
Parameters
----------
layer : QgsVectorLayer
@@ -120,7 +123,7 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
target_crs : QgsCoordinateReferenceSystem, optional
If provided, all geometries will be transformed to this CRS.
If None, the layer's source CRS is used.
-
+
Returns
-------
gpd.GeoDataFrame
@@ -128,28 +131,28 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
"""
if layer is None:
return None
-
+
features = layer.getFeatures()
fields = layer.fields()
data = {'geometry': []}
for f in fields:
data[f.name()] = []
-
+
# Set up coordinate transformation if needed
transform = None
source_crs = layer.sourceCrs()
output_crs = source_crs
-
+
if target_crs is not None and target_crs.isValid():
if source_crs.isValid() and source_crs != target_crs:
transform = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
output_crs = target_crs
-
+
for feature in features:
geom = feature.geometry()
if geom.isEmpty():
continue
-
+
# Transform geometry if needed
if transform is not None:
geom_copy = QgsGeometry(geom)
@@ -157,38 +160,34 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> gpd.GeoDataFrame:
result = geom_copy.transform(transform)
if result != 0:
# Transform returned error code
- warnings.warn(
+ logger.warning(
f"Failed to transform geometry (error code {result}). "
f"Source CRS: {_get_crs_id(source_crs)}, Target CRS: {_get_crs_id(target_crs)}. "
- f"Skipping feature.",
- RuntimeWarning
+ f"Skipping feature."
)
continue
- data['geometry'].append(geom_copy)
except Exception as e:
# If transformation fails, log warning and skip this feature
- warnings.warn(
+ logger.exception(
f"Exception during CRS transformation: {e}. "
f"Source CRS: {_get_crs_id(source_crs)}, Target CRS: {_get_crs_id(target_crs)}. "
- f"Skipping feature.",
- RuntimeWarning
+ f"Skipping feature."
)
continue
else:
data['geometry'].append(geom)
-
+
# Copy field values
for f in fields:
if f.type() == QVariant.String:
data[f.name()].append(str(feature[f.name()]))
else:
data[f.name()].append(feature[f.name()])
-
- return gpd.GeoDataFrame(data, crs=output_crs.authid())
+ return gpd.GeoDataFrame(data, crs=output_crs.authid())
-def qgsLayerToDataFrame(src, dtm=None) -> pd.DataFrame:
+def qgsLayerToDataFrame(src, dtm=None) -> Optional[pd.DataFrame]:
"""
Convert a vector layer or processing feature source to a pandas DataFrame.
Samples geometry using points or vertices of lines/polygons.