qgis-tem-loader

qgis plugin for loading TEM geophysical inversion XYZ files as 3D objects
git clone git://src.adamsgaard.dk/qgis-tem-loader # fast
git clone https://src.adamsgaard.dk/qgis-tem-loader.git # slow
Log | Files | Refs | README | LICENSE Back to index

commit f689cd6bd1d37813ce21b1d777a145cdc498dcea
parent 99c3ac3a0f150a3271b3b16e357d3efcd8048b80
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Thu, 22 Oct 2026 09:08:41 +0000

feat(qgis): add mean resistivity maps dialog

Diffstat:
Mtem_loader/tem_loader.py | 302+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtest/test_core.py | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 352 insertions(+), 6 deletions(-)

diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py @@ -30,6 +30,7 @@ from qgis.core import ( QgsGeometry, QgsPointXY, QgsProject, + QgsRasterLayer, QgsVectorFileWriter, QgsVectorLayer, ) @@ -37,6 +38,8 @@ from qgis.gui import QgsMapLayerComboBox from . import core from . import interpolate as interp +from . import mean_resistivity as mres +from .geotiff import write_geotiff STYLES_DIR = Path(__file__).parent / 'styles' @@ -49,18 +52,26 @@ def _build_geopackage_uri(gpkg_path, layer_name): return f'{gpkg_path.resolve()}|layername={layer_name}' -def _voxel_output_path(input_layers): - """Auto-pick the voxel GeoPackage path next to the import's GeoPackage, - named after it with a ``_voxels`` suffix. Returns ``None`` if no input layer - is backed by a GeoPackage on disk.""" +def _output_path(input_layers, suffix): + """Auto-pick an output path next to the import's GeoPackage, named after it + with ``suffix`` appended to its stem. Returns ``None`` if no input layer is + backed by a GeoPackage on disk.""" for lyr in input_layers: source = lyr.source().split('|', 1)[0] src = Path(source) if src.suffix.lower() == '.gpkg' and src.parent.is_dir(): - return src.with_name(f'{src.stem}_voxels.gpkg') + return src.with_name(f'{src.stem}{suffix}') return None +def _mean_resistivity_file_name(top, bottom): + return f'mean_resistivity_{top:g}_{bottom:g}.tif' + + +def _mean_resistivity_layer_name(top, bottom): + return f'Mean resistivity {top:g} to {bottom:g} m' + + def _field_type(field_name): if field_name in STRING_FIELDS: return QMetaType.Type.QString @@ -570,11 +581,186 @@ class _InterpolationDialog(QDialog): } +def _layer_list(layers, wkb_type, preselect=None): + widget = QListWidget() + widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection + if hasattr(QListWidget, 'SelectionMode') + else QListWidget.MultiSelection) + for lyr in layers: + if hasattr(lyr, 'wkbType') and lyr.wkbType() == wkb_type: + item = QListWidgetItem(lyr.name()) + item.setData(32, lyr) + widget.addItem(item) + if preselect and preselect in lyr.name().lower(): + item.setSelected(True) + return widget + + +def _selected_list_layers(widget): + layers = [] + for i in range(widget.count()): + item = widget.item(i) + if item.isSelected() and item.data(32) is not None: + layers.append(item.data(32)) + return layers + + +class _MeanResistivityDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle('Mean Resistivity Maps') + + all_layers = list(QgsProject.instance().mapLayers().values()) + self._input_list = _layer_list(all_layers, Qgis.WkbType.LineStringZ) + self._input_list.itemSelectionChanged.connect(self._update_summary) + self._doi_list = _layer_list(all_layers, Qgis.WkbType.PointZ, 'doi') + + self._top = QDoubleSpinBox() + self._bottom = QDoubleSpinBox() + for sb in (self._top, self._bottom): + sb.setRange(-1e5, 1e5) + sb.setDecimals(1) + sb.setSuffix(' m') + self._top.setValue(50.0) + self._bottom.setValue(-50.0) + + self._thickness = QDoubleSpinBox() + self._thickness.setRange(0.1, 1e4) + self._thickness.setDecimals(1) + self._thickness.setSuffix(' m') + self._thickness.setValue(10.0) + + self._cell_size = QDoubleSpinBox() + self._cell_size.setRange(0.1, 1e5) + self._cell_size.setDecimals(1) + self._cell_size.setSuffix(' m') + self._cell_size.setValue(50.0) + + self._radius = QDoubleSpinBox() + self._radius.setRange(0.0, 1e6) + self._radius.setDecimals(1) + self._radius.setValue(0.0) + self._radius.setSpecialValueText('Auto') + + self._power = QDoubleSpinBox() + self._power.setRange(0.1, 10.0) + self._power.setDecimals(1) + self._power.setValue(2.0) + + self._min_neighbors = QSpinBox() + self._min_neighbors.setRange(1, 1000) + self._min_neighbors.setValue(1) + + self._min_coverage = QSpinBox() + self._min_coverage.setRange(0, 100) + self._min_coverage.setSuffix('%') + self._min_coverage.setValue(50) + + self._summary_label = QLabel() + + for sb in (self._top, self._bottom, self._thickness, self._cell_size): + sb.valueChanged.connect(self._update_summary) + + use_range_btn = QPushButton( + 'Use elevation range of selected input layers' + ) + use_range_btn.clicked.connect(self._fill_range_from_inputs) + + buttons = QDialogButtonBox(_dialog_buttons()) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + interval_form = QFormLayout() + interval_form.addRow('Top elevation', self._top) + interval_form.addRow('Bottom elevation', self._bottom) + interval_form.addRow('Interval thickness', self._thickness) + + param_form = QFormLayout() + param_form.addRow('Cell size', self._cell_size) + param_form.addRow('Search radius (m)', self._radius) + param_form.addRow('IDW power', self._power) + param_form.addRow('Min neighbors', self._min_neighbors) + param_form.addRow('Minimum interval coverage', self._min_coverage) + param_form.addRow('', self._summary_label) + + layout = QVBoxLayout() + layout.addWidget(QLabel('Input layers (LineStringZ):')) + layout.addWidget(self._input_list) + layout.addWidget(QLabel('DOI layers (PointZ, optional):')) + layout.addWidget(self._doi_list) + layout.addWidget(use_range_btn) + layout.addLayout(interval_form) + layout.addLayout(param_form) + layout.addWidget(buttons) + self.setLayout(layout) + + self._update_summary() + + def _fill_range_from_inputs(self): + tops, bottoms = [], [] + for lyr in _selected_list_layers(self._input_list): + fields = lyr.fields() + for name, values, pick in (('ZTop', tops, lyr.maximumValue), + ('ZBottom', bottoms, lyr.minimumValue)): + index = fields.indexOf(name) + if index < 0: + continue + value = pick(index) + if isinstance(value, (int, float)) and value == value: + values.append(float(value)) + if not tops or not bottoms: + return + step = self._thickness.value() + self._top.setValue(math.ceil(max(tops) / step) * step) + self._bottom.setValue(math.floor(min(bottoms) / step) * step) + + def _update_summary(self, *_args): + try: + intervals = mres.elevation_intervals( + self._top.value(), self._bottom.value(), + self._thickness.value(), + ) + except ValueError as exc: + self._summary_label.setText(str(exc).capitalize() + '.') + return + text = f'{len(intervals)} intervals' + layers = _selected_list_layers(self._input_list) + if layers: + xs, ys = [], [] + for lyr in layers: + ext = lyr.extent() + xs += [ext.xMinimum(), ext.xMaximum()] + ys += [ext.yMinimum(), ext.yMaximum()] + try: + grid = mres.map_grid(xs, ys, self._cell_size.value()) + text += f', {grid.nx} × {grid.ny} cells' + except ValueError as exc: + text += f'; {exc}' + self._summary_label.setText(text) + + def options(self): + radius = self._radius.value() + return { + 'input_layers': _selected_list_layers(self._input_list), + 'doi_layers': _selected_list_layers(self._doi_list), + 'intervals': mres.elevation_intervals( + self._top.value(), self._bottom.value(), + self._thickness.value(), + ), + 'cell_size': self._cell_size.value(), + 'radius': radius if radius > 0 else None, + 'power': self._power.value(), + 'min_neighbors': self._min_neighbors.value(), + 'min_coverage': self._min_coverage.value() / 100.0, + } + + class TEMLoaderPlugin: def __init__(self, iface): self.iface = iface self._action = None self._interp_action = None + self._mean_res_action = None def initGui(self): self._action = QAction('Load TEM XYZ files\u2026', self.iface.mainWindow()) @@ -585,12 +771,21 @@ class TEMLoaderPlugin: self._interp_action.triggered.connect(self.run_interpolation) self.iface.addPluginToMenu('TEM Loader', self._interp_action) + self._mean_res_action = QAction( + 'Mean resistivity maps\u2026', self.iface.mainWindow() + ) + self._mean_res_action.triggered.connect(self.run_mean_resistivity_maps) + self.iface.addPluginToMenu('TEM Loader', self._mean_res_action) + def unload(self): self.iface.removePluginMenu('TEM Loader', self._action) self._action = None if self._interp_action is not None: self.iface.removePluginMenu('TEM Loader', self._interp_action) self._interp_action = None + if self._mean_res_action is not None: + self.iface.removePluginMenu('TEM Loader', self._mean_res_action) + self._mean_res_action = None def run(self): paths, _ = QFileDialog.getOpenFileNames( @@ -660,7 +855,7 @@ class TEMLoaderPlugin: ) return - gpkg_path = _voxel_output_path(input_layers) + gpkg_path = _output_path(input_layers, '_voxels.gpkg') if gpkg_path is None: QMessageBox.warning( self.iface.mainWindow(), @@ -785,6 +980,101 @@ class TEMLoaderPlugin: 'TEM Loader', f'Voxel layer saved to {gpkg_path}' ) + def run_mean_resistivity_maps(self): + dialog = _MeanResistivityDialog(self.iface.mainWindow()) + if _exec_dialog(dialog) != _dialog_code('Accepted'): + return + try: + self._run_mean_resistivity_maps(dialog.options()) + except ValueError as exc: + QMessageBox.warning(self.iface.mainWindow(), 'TEM Loader', str(exc)) + + def _run_mean_resistivity_maps(self, opts): + input_layers = opts['input_layers'] + if not input_layers: + raise ValueError('No input layers selected.') + required = ('X', 'Y', 'ZTop', 'ZBottom', 'Resistivity') + for lyr in input_layers: + names = {field.name() for field in lyr.fields()} + missing = [name for name in required if name not in names] + if missing: + raise ValueError( + f'Layer "{lyr.name()}" is missing required field(s): ' + f'{", ".join(missing)}. Select TEM "layers" outputs.' + ) + out_dir = _output_path(input_layers, '_mean_resistivity') + if out_dir is None: + raise ValueError( + 'Could not determine an output location: the selected input ' + 'layers are not backed by a GeoPackage on disk.' + ) + + layer_rows = [ + {name: feat[name] for name in required} + for lyr in input_layers for feat in lyr.getFeatures() + ] + doi_rows = [ + {name: feat[name] for name in ('X', 'Y', 'ZDOI')} + for lyr in opts['doi_layers'] for feat in lyr.getFeatures() + ] + + progress_dialog = QProgressDialog( + 'Computing mean resistivity maps…', 'Cancel', 0, 100, + self.iface.mainWindow(), + ) + progress_dialog.setWindowTitle('TEM Loader') + progress_dialog.setMinimumDuration(500) + + def on_progress(done, total): + progress_dialog.setValue(int(100 * done / total)) + return progress_dialog.wasCanceled() + + grid, maps = mres.mean_resistivity_maps( + layer_rows, + opts['intervals'], + opts['cell_size'], + doi_points=doi_rows or None, + power=opts['power'], + radius=opts['radius'], + min_neighbors=opts['min_neighbors'], + min_coverage=opts['min_coverage'], + progress=on_progress, + ) + progress_dialog.setValue(100) + if progress_dialog.wasCanceled(): + raise ValueError('Mean resistivity maps cancelled.') + if not maps: + raise ValueError( + 'No mean resistivity maps produced. Check the elevation range ' + 'and input layers.' + ) + + out_dir.mkdir(exist_ok=True) + crs_wkt = input_layers[0].crs().toWkt() + paths = [] + for top, bottom, array, _ in maps: + path = out_dir / _mean_resistivity_file_name(top, bottom) + write_geotiff(path, grid, array, crs_wkt) + paths.append((path, _mean_resistivity_layer_name(top, bottom))) + + project = QgsProject.instance() + group = project.layerTreeRoot().insertGroup(0, 'Mean resistivity') + qml = STYLES_DIR / 'mean_resistivity.qml' + for index, (path, name) in enumerate(paths): + layer = QgsRasterLayer(str(path), name, 'gdal') + if not layer.isValid(): + raise ValueError(f'Failed to load mean resistivity map {path}') + layer.loadNamedStyle(str(qml)) + project.addMapLayer(layer, False) + node = group.insertLayer(index, layer) + if index > 0: + node.setItemVisibilityChecked(False) + + self.iface.messageBar().pushInfo( + 'TEM Loader', + f'{len(paths)} mean resistivity maps saved to {out_dir}', + ) + def _load_xyz( self, filepath, diff --git a/test/test_core.py b/test/test_core.py @@ -1088,6 +1088,9 @@ class PluginTests(unittest.TestCase): qgis_gui = types.ModuleType("qgis.gui") qgis_gui.QgsMapLayerComboBox = FakeMapLayerComboBox + osgeo = types.ModuleType("osgeo") + osgeo.gdal = types.ModuleType("osgeo.gdal") + module_map = { "qgis": types.ModuleType("qgis"), "qgis.PyQt": types.ModuleType("qgis.PyQt"), @@ -1095,6 +1098,8 @@ class PluginTests(unittest.TestCase): "qgis.PyQt.QtWidgets": qtwidgets, "qgis.core": qgis_core, "qgis.gui": qgis_gui, + "osgeo": osgeo, + "osgeo.gdal": osgeo.gdal, } with patch.dict(sys.modules, module_map): @@ -2189,6 +2194,57 @@ class PluginTests(unittest.TestCase): self.assertEqual(active.attrib["value"], "true") self.assertEqual(expr.attrib["value"], '"Color"') + def test_output_path_appends_suffix_to_geopackage_stem(self): + module, _, _ = self._import_plugin_module() + with TemporaryDirectory() as tmp: + gpkg = Path(tmp) / "survey.gpkg" + layers = [Mock(**{"source.return_value": "/missing/a.csv"}), + Mock(**{"source.return_value": + f"{gpkg}|layername=layers"})] + + self.assertEqual( + module._output_path(layers, "_mean_resistivity"), + Path(tmp) / "survey_mean_resistivity", + ) + self.assertIsNone(module._output_path(layers[:1], "_voxels.gpkg")) + + def test_mean_resistivity_names(self): + module, _, _ = self._import_plugin_module() + self.assertEqual(module._mean_resistivity_file_name(0.0, -10.0), + "mean_resistivity_0_-10.tif") + self.assertEqual(module._mean_resistivity_layer_name(12.5, 2.5), + "Mean resistivity 12.5 to 2.5 m") + + def test_mean_resistivity_requires_input_layers(self): + module, _, _ = self._import_plugin_module() + plugin = module.TEMLoaderPlugin(Mock()) + with self.assertRaisesRegex(ValueError, "No input layers"): + plugin._run_mean_resistivity_maps({"input_layers": []}) + + def test_mean_resistivity_requires_layer_fields(self): + module, _, _ = self._import_plugin_module() + plugin = module.TEMLoaderPlugin(Mock()) + layer = Mock() + layer.name.return_value = "points" + layer.fields.return_value = [module.QgsField("X", None), + module.QgsField("Y", None)] + with self.assertRaisesRegex(ValueError, "ZTop, ZBottom, Resistivity"): + plugin._run_mean_resistivity_maps({"input_layers": [layer]}) + + def test_run_mean_resistivity_maps_shows_errors(self): + module, _, message_box = self._import_plugin_module() + iface = Mock() + dialog = Mock() + dialog.options.return_value = {"input_layers": []} + module._MeanResistivityDialog = Mock(return_value=dialog) + module._exec_dialog = Mock(return_value=module.QDialog.Accepted) + plugin = module.TEMLoaderPlugin(iface) + + plugin.run_mean_resistivity_maps() + + self.assertEqual(len(message_box.warnings), 1) + self.assertIn("No input layers", message_box.warnings[0][2]) + def test_integer_fields_includes_voxel_attrs(self): module, _, _ = self._import_plugin_module() for name in ('NumSamples', 'CellX', 'CellY', 'CellZ'):