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

tem_loader.py (41054B)


      1 import math
      2 from pathlib import Path
      3 
      4 from qgis.PyQt.QtCore import QMetaType
      5 from qgis.PyQt.QtWidgets import (
      6     QAction,
      7     QCheckBox,
      8     QDialog,
      9     QDialogButtonBox,
     10     QDoubleSpinBox,
     11     QFileDialog,
     12     QFormLayout,
     13     QLabel,
     14     QListWidget,
     15     QListWidgetItem,
     16     QMessageBox,
     17     QProgressDialog,
     18     QPushButton,
     19     QSpinBox,
     20     QVBoxLayout,
     21 )
     22 from qgis.core import (
     23     Qgis,
     24     QgsCoordinateReferenceSystem,
     25     QgsCoordinateTransform,
     26     QgsCsException,
     27     QgsFeature,
     28     QgsField,
     29     QgsFields,
     30     QgsGeometry,
     31     QgsPointXY,
     32     QgsProject,
     33     QgsRasterLayer,
     34     QgsVectorFileWriter,
     35     QgsVectorLayer,
     36 )
     37 from qgis.gui import QgsMapLayerComboBox
     38 
     39 from . import core
     40 from . import interpolate as interp
     41 from . import mean_resistivity as mres
     42 from .geotiff import write_geotiff
     43 
     44 
     45 STYLES_DIR = Path(__file__).parent / 'styles'
     46 GEOMETRY_FIELD = 'Geometry'
     47 STRING_FIELDS = {'Line', 'StationNo', 'Color'}
     48 INTEGER_FIELDS = {'NumLayers', 'Layer', 'Opacity', 'NumSamples', 'CellX', 'CellY', 'CellZ'}
     49 
     50 
     51 def _build_geopackage_uri(gpkg_path, layer_name):
     52     return f'{gpkg_path.resolve()}|layername={layer_name}'
     53 
     54 
     55 def _output_path(input_layers, suffix):
     56     """Auto-pick an output path next to the import's GeoPackage, named after it
     57     with ``suffix`` appended to its stem. Returns ``None`` if no input layer is
     58     backed by a GeoPackage on disk."""
     59     for lyr in input_layers:
     60         source = lyr.source().split('|', 1)[0]
     61         src = Path(source)
     62         if src.suffix.lower() == '.gpkg' and src.parent.is_dir():
     63             return src.with_name(f'{src.stem}{suffix}')
     64     return None
     65 
     66 
     67 def _mean_resistivity_file_name(top, bottom):
     68     return f'mean_resistivity_{top:g}_{bottom:g}.tif'
     69 
     70 
     71 def _mean_resistivity_layer_name(top, bottom):
     72     return f'Mean resistivity {top:g} to {bottom:g} m'
     73 
     74 
     75 def _field_type(field_name):
     76     if field_name in STRING_FIELDS:
     77         return QMetaType.Type.QString
     78     if field_name in INTEGER_FIELDS:
     79         return QMetaType.Type.Int
     80     return QMetaType.Type.Double
     81 
     82 
     83 def _build_fields(rows):
     84     fields = QgsFields()
     85     if not rows:
     86         return fields
     87     for field_name in rows[0]:
     88         if field_name == GEOMETRY_FIELD:
     89             continue
     90         fields.append(QgsField(field_name, _field_type(field_name)))
     91     return fields
     92 
     93 
     94 def _rows_to_features(rows, fields):
     95     field_names = [field.name() for field in fields]
     96     features = []
     97     for row in rows:
     98         feature = QgsFeature(fields)
     99         feature.setGeometry(QgsGeometry.fromWkt(row[GEOMETRY_FIELD]))
    100         feature.setAttributes([row.get(name) for name in field_names])
    101         features.append(feature)
    102     return features
    103 
    104 
    105 def _writer_no_error():
    106     writer_error = getattr(QgsVectorFileWriter, 'WriterError', QgsVectorFileWriter)
    107     return writer_error.NoError
    108 
    109 
    110 def _dialog_button(name):
    111     standard_button = getattr(
    112         QDialogButtonBox,
    113         'StandardButton',
    114         QDialogButtonBox,
    115     )
    116     return getattr(standard_button, name)
    117 
    118 
    119 def _dialog_buttons():
    120     return _dialog_button('Ok') | _dialog_button('Cancel')
    121 
    122 
    123 def _dialog_code(name):
    124     dialog_code = getattr(QDialog, 'DialogCode', QDialog)
    125     return getattr(dialog_code, name)
    126 
    127 
    128 def _layer_filter(name):
    129     layer_filter = getattr(Qgis, 'LayerFilter', Qgis)
    130     return getattr(layer_filter, name)
    131 
    132 
    133 def _warn_dem_sample_failed(raster_layer, x, y):
    134     print(
    135         'TEM Loader warning: '
    136         f'point ({x}, {y}) is outside DEM raster {raster_layer.name()}'
    137     )
    138 
    139 
    140 def _sample_dem_elevation(raster_layer, source_crs, project, x, y):
    141     transform = QgsCoordinateTransform(source_crs, raster_layer.crs(), project)
    142     try:
    143         raster_point = transform.transform(QgsPointXY(x, y))
    144     except QgsCsException as exc:
    145         print(
    146             'TEM Loader warning: '
    147             f'could not transform point ({x}, {y}) to DEM raster '
    148             f'{raster_layer.name()}: {exc}'
    149         )
    150         return None
    151 
    152     value, ok = raster_layer.dataProvider().sample(raster_point, 1)
    153     try:
    154         elevation = float(value)
    155     except (TypeError, ValueError):
    156         elevation = math.nan
    157     if not ok or math.isnan(elevation):
    158         _warn_dem_sample_failed(raster_layer, x, y)
    159         return None
    160     return elevation
    161 
    162 
    163 def _point_wkt(x, y, z):
    164     return f'POINT Z ({x} {y} {z})'
    165 
    166 
    167 def _line_wkt(x, y, z_top, z_bottom):
    168     return f'LINESTRING Z ({x} {y} {z_top}, {x} {y} {z_bottom})'
    169 
    170 
    171 def _adjust_rows_to_dem(points, doi_points, layers, raster_layer, source_crs,
    172                         project):
    173     if raster_layer is None:
    174         return
    175 
    176     dem_z_by_xy = {}
    177     for point in points:
    178         x = point['X']
    179         y = point['Y']
    180         dem_z = _sample_dem_elevation(raster_layer, source_crs, project, x, y)
    181         if dem_z is None:
    182             continue
    183         dem_z_by_xy[(x, y)] = dem_z
    184         point['Z'] = dem_z
    185         point['Geometry'] = _point_wkt(x, y, dem_z)
    186 
    187     for doi_point in doi_points:
    188         x = doi_point['X']
    189         y = doi_point['Y']
    190         dem_z = dem_z_by_xy.get((x, y))
    191         if dem_z is None:
    192             continue
    193         z_doi = dem_z - doi_point['DOI']
    194         doi_point['Z'] = z_doi
    195         doi_point['ZDOI'] = z_doi
    196         doi_point['Geometry'] = _point_wkt(x, y, z_doi)
    197 
    198     for layer in layers:
    199         x = layer['X']
    200         y = layer['Y']
    201         dem_z = dem_z_by_xy.get((x, y))
    202         if dem_z is None:
    203             continue
    204         z_top = dem_z - layer['DepthTop']
    205         z_bottom = dem_z - layer['DepthBottom']
    206         z_mid = (z_top + z_bottom) / 2
    207         layer['Z'] = dem_z
    208         layer['ZTop'] = z_top
    209         layer['ZMid'] = z_mid
    210         layer['ZBottom'] = z_bottom
    211         layer['Geometry'] = _line_wkt(x, y, z_top, z_bottom)
    212 
    213 
    214 def _write_geopackage_layer(rows, gpkg_path, layer_name, wkb_type, crs,
    215                             transform_context, action):
    216     fields = _build_fields(rows)
    217     options = QgsVectorFileWriter.SaveVectorOptions()
    218     options.driverName = 'GPKG'
    219     options.fileEncoding = 'UTF-8'
    220     options.layerName = layer_name
    221     options.actionOnExistingFile = action
    222 
    223     writer = QgsVectorFileWriter.create(
    224         str(gpkg_path),
    225         fields,
    226         wkb_type,
    227         crs,
    228         transform_context,
    229         options,
    230     )
    231     if writer is None:
    232         raise ValueError(f'failed to create GeoPackage layer {layer_name}')
    233 
    234     try:
    235         if writer.hasError() != _writer_no_error():
    236             message = writer.errorMessage()
    237             details = f': {message}' if message else ''
    238             raise ValueError(
    239                 f'failed to create GeoPackage layer {layer_name}{details}'
    240             )
    241         features = _rows_to_features(rows, fields)
    242         if features and not writer.addFeatures(features):
    243             raise ValueError(f'failed to write GeoPackage layer {layer_name}')
    244     finally:
    245         del writer
    246 
    247 
    248 class _ImportOptionsDialog(QDialog):
    249     def __init__(self, parent=None):
    250         super().__init__(parent)
    251         self.setWindowTitle('TEM Loader Options')
    252 
    253         self._clip_checkbox = QCheckBox(
    254             'Hide layers below depth of interest (DOI, 2D and 3D views)'
    255         )
    256         self._clip_checkbox.setChecked(True)
    257 
    258         self._mask_checkbox = QCheckBox(
    259             'Make layers below DOI partially transparent (2D view only)'
    260         )
    261         self._mask_checkbox.setChecked(True)
    262 
    263         self._opacity_spinbox = QSpinBox()
    264         self._opacity_spinbox.setRange(0, 100)
    265         self._opacity_spinbox.setSuffix('%')
    266         self._opacity_spinbox.setValue(core.BELOW_DOI_OPACITY)
    267 
    268         self._clip_checkbox.toggled.connect(self._update_below_doi_controls)
    269         self._mask_checkbox.toggled.connect(self._update_below_doi_controls)
    270         self._update_below_doi_controls()
    271 
    272         self._dem_checkbox = QCheckBox(
    273             'Adjust vertical position to digital elevation model'
    274         )
    275         self._dem_checkbox.setChecked(False)
    276 
    277         self._dem_raster_combo = QgsMapLayerComboBox()
    278         self._dem_raster_combo.setProject(QgsProject.instance())
    279         self._dem_raster_combo.setFilters(_layer_filter('RasterLayer'))
    280         self._dem_raster_combo.setAllowEmptyLayer(True, 'No elevation raster')
    281         self._dem_raster_combo.setEnabled(False)
    282         self._dem_checkbox.toggled.connect(self._dem_raster_combo.setEnabled)
    283 
    284         opacity_form = QFormLayout()
    285         opacity_form.addRow('Opacity', self._opacity_spinbox)
    286 
    287         dem_form = QFormLayout()
    288         dem_form.addRow('Elevation raster', self._dem_raster_combo)
    289 
    290         buttons = QDialogButtonBox(_dialog_buttons())
    291         buttons.accepted.connect(self.accept)
    292         buttons.rejected.connect(self.reject)
    293 
    294         layout = QVBoxLayout()
    295         layout.addWidget(self._clip_checkbox)
    296         layout.addWidget(self._mask_checkbox)
    297         layout.addLayout(opacity_form)
    298         layout.addWidget(self._dem_checkbox)
    299         layout.addLayout(dem_form)
    300         layout.addWidget(buttons)
    301         self.setLayout(layout)
    302 
    303     def _update_below_doi_controls(self, *_args):
    304         clipping = self._clip_checkbox.isChecked()
    305         self._mask_checkbox.setEnabled(not clipping)
    306         self._opacity_spinbox.setEnabled(
    307             not clipping and self._mask_checkbox.isChecked()
    308         )
    309 
    310     def options(self):
    311         elevation_raster_layer = None
    312         if self._dem_checkbox.isChecked():
    313             elevation_raster_layer = self._dem_raster_combo.currentLayer()
    314         return {
    315             'clip_below_doi': self._clip_checkbox.isChecked(),
    316             'mask_below_doi': self._mask_checkbox.isChecked(),
    317             'below_doi_opacity': self._opacity_spinbox.value(),
    318             'elevation_raster_layer': elevation_raster_layer,
    319         }
    320 
    321 
    322 def _exec_dialog(dialog):
    323     execute = getattr(dialog, 'exec', None)
    324     if execute is None:
    325         execute = dialog.exec_
    326     return execute()
    327 
    328 
    329 class _InterpolationDialog(QDialog):
    330     def __init__(self, parent=None):
    331         super().__init__(parent)
    332         self.setWindowTitle('3D IDW Interpolation')
    333 
    334         project = QgsProject.instance()
    335         all_layers = list(project.mapLayers().values()) if hasattr(project, 'mapLayers') else []
    336 
    337         self._input_list = QListWidget()
    338         self._input_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection
    339                                           if hasattr(QListWidget, 'SelectionMode')
    340                                           else QListWidget.MultiSelection)
    341         linestring_z_wkb = Qgis.WkbType.LineStringZ
    342         for lyr in all_layers:
    343             if hasattr(lyr, 'wkbType') and lyr.wkbType() == linestring_z_wkb:
    344                 item = QListWidgetItem(lyr.name())
    345                 item.setData(32, lyr)
    346                 self._input_list.addItem(item)
    347 
    348         self._doi_list = QListWidget()
    349         self._doi_list.setSelectionMode(QListWidget.SelectionMode.MultiSelection
    350                                         if hasattr(QListWidget, 'SelectionMode')
    351                                         else QListWidget.MultiSelection)
    352         point_z_wkb = Qgis.WkbType.PointZ
    353         for lyr in all_layers:
    354             if hasattr(lyr, 'wkbType') and lyr.wkbType() == point_z_wkb:
    355                 item = QListWidgetItem(lyr.name())
    356                 item.setData(32, lyr)
    357                 self._doi_list.addItem(item)
    358                 if 'doi' in lyr.name().lower():
    359                     item.setSelected(True)
    360 
    361         self._xmin = QDoubleSpinBox()
    362         self._xmax = QDoubleSpinBox()
    363         self._ymin = QDoubleSpinBox()
    364         self._ymax = QDoubleSpinBox()
    365         self._zmin = QDoubleSpinBox()
    366         self._zmax = QDoubleSpinBox()
    367         for sb in (self._xmin, self._xmax, self._ymin, self._ymax,
    368                    self._zmin, self._zmax):
    369             sb.setRange(-1e9, 1e9)
    370             sb.setDecimals(3)
    371 
    372         self._dx = QDoubleSpinBox()
    373         self._dy = QDoubleSpinBox()
    374         self._dz = QDoubleSpinBox()
    375         for sb in (self._dx, self._dy, self._dz):
    376             sb.setRange(0.001, 1e6)
    377             sb.setDecimals(3)
    378             sb.setValue(100.0)
    379         self._dz.setValue(10.0)
    380         # Cell counts are derived from extent and cell size, not editable.
    381         self._nx = QLabel('—')
    382         self._ny = QLabel('—')
    383         self._nz = QLabel('—')
    384         self._count_x = self._count_y = self._count_z = None
    385 
    386         self._summary_label = QLabel()
    387 
    388         self._power = QDoubleSpinBox()
    389         self._power.setRange(0.1, 10.0)
    390         self._power.setValue(2.0)
    391         self._power.setDecimals(1)
    392 
    393         self._log10_checkbox = QCheckBox('Interpolate in log10 space')
    394         self._log10_checkbox.setChecked(True)
    395 
    396         self._clip_doi_checkbox = QCheckBox('Clip voxels below per-sounding DOI')
    397         self._clip_doi_checkbox.setChecked(True)
    398 
    399         self._radius = QDoubleSpinBox()
    400         self._radius.setRange(0.0, 1e9)
    401         self._radius.setDecimals(2)
    402         self._radius.setValue(0.0)
    403         self._radius.setSpecialValueText('Auto')
    404 
    405         self._radius_z = QDoubleSpinBox()
    406         self._radius_z.setRange(0.0, 1e9)
    407         self._radius_z.setDecimals(2)
    408         self._radius_z.setValue(0.0)
    409         self._radius_z.setSpecialValueText('Auto')
    410 
    411         self._min_neighbors = QSpinBox()
    412         self._min_neighbors.setRange(1, 1000)
    413         self._min_neighbors.setValue(1)
    414 
    415         self._max_neighbors = QSpinBox()
    416         self._max_neighbors.setRange(0, 10000)
    417         self._max_neighbors.setValue(0)
    418         self._max_neighbors.setSpecialValueText('Unlimited')
    419 
    420         use_extent_btn = QPushButton('Use extent of selected input layers')
    421         use_extent_btn.clicked.connect(self._fill_extent_from_inputs)
    422 
    423         self._updating = False
    424 
    425         for sb in (self._dx, self._dy, self._dz,
    426                    self._xmin, self._xmax, self._ymin, self._ymax,
    427                    self._zmin, self._zmax):
    428             sb.valueChanged.connect(self._recompute_counts)
    429 
    430         buttons = QDialogButtonBox(_dialog_buttons())
    431         buttons.accepted.connect(self.accept)
    432         buttons.rejected.connect(self.reject)
    433 
    434         extent_form = QFormLayout()
    435         extent_form.addRow('X min', self._xmin)
    436         extent_form.addRow('X max', self._xmax)
    437         extent_form.addRow('Y min', self._ymin)
    438         extent_form.addRow('Y max', self._ymax)
    439         extent_form.addRow('Z min', self._zmin)
    440         extent_form.addRow('Z max', self._zmax)
    441 
    442         size_form = QFormLayout()
    443         size_form.addRow('Cell size X (m)', self._dx)
    444         size_form.addRow('Cell size Y (m)', self._dy)
    445         size_form.addRow('Cell size Z (m)', self._dz)
    446         size_form.addRow('Cell count X', self._nx)
    447         size_form.addRow('Cell count Y', self._ny)
    448         size_form.addRow('Cell count Z', self._nz)
    449         size_form.addRow('', self._summary_label)
    450 
    451         param_form = QFormLayout()
    452         param_form.addRow('IDW power', self._power)
    453         param_form.addRow('Horizontal search radius (m)', self._radius)
    454         param_form.addRow('Vertical search radius (m)', self._radius_z)
    455         param_form.addRow('Min neighbors', self._min_neighbors)
    456         param_form.addRow('Max neighbors', self._max_neighbors)
    457 
    458         layout = QVBoxLayout()
    459         layout.addWidget(QLabel('Input layers (LineStringZ):'))
    460         layout.addWidget(self._input_list)
    461         layout.addWidget(QLabel('DOI layers (PointZ, optional):'))
    462         layout.addWidget(self._doi_list)
    463         layout.addWidget(use_extent_btn)
    464         layout.addLayout(extent_form)
    465         layout.addLayout(size_form)
    466         layout.addWidget(self._log10_checkbox)
    467         layout.addWidget(self._clip_doi_checkbox)
    468         layout.addLayout(param_form)
    469         layout.addWidget(buttons)
    470         self.setLayout(layout)
    471 
    472         self._recompute_counts()
    473 
    474     def _fill_extent_from_inputs(self):
    475         layers = self._selected_input_layers()
    476         if not layers:
    477             return
    478         xs, ys, zs = [], [], []
    479         for lyr in layers:
    480             ext = lyr.extent()
    481             xs += [ext.xMinimum(), ext.xMaximum()]
    482             ys += [ext.yMinimum(), ext.yMaximum()]
    483             for feat in lyr.getFeatures():
    484                 geom = feat.geometry()
    485                 if geom is None:
    486                     continue
    487                 for vertex in geom.vertices():
    488                     z = vertex.z()
    489                     if z == z:  # exclude NaN
    490                         zs.append(z)
    491         self._updating = True
    492         self._xmin.setValue(min(xs))
    493         self._xmax.setValue(max(xs))
    494         self._ymin.setValue(min(ys))
    495         self._ymax.setValue(max(ys))
    496         if zs:
    497             self._zmin.setValue(min(zs))
    498             self._zmax.setValue(max(zs))
    499         self._updating = False
    500         self._recompute_counts()
    501 
    502     def _selected_input_layers(self):
    503         layers = []
    504         for i in range(self._input_list.count()):
    505             item = self._input_list.item(i)
    506             if item.isSelected():
    507                 lyr = item.data(32)
    508                 if lyr is not None:
    509                     layers.append(lyr)
    510         return layers
    511 
    512     def _selected_doi_layers(self):
    513         layers = []
    514         for i in range(self._doi_list.count()):
    515             item = self._doi_list.item(i)
    516             if item.isSelected():
    517                 lyr = item.data(32)
    518                 if lyr is not None:
    519                     layers.append(lyr)
    520         return layers
    521 
    522     @staticmethod
    523     def _axis_count(extent, cell_size):
    524         try:
    525             return interp.axis_count(extent, cell_size)
    526         except (ValueError, ZeroDivisionError):
    527             return None
    528 
    529     def _recompute_counts(self):
    530         if self._updating:
    531             return
    532         self._count_x = self._axis_count(
    533             self._xmax.value() - self._xmin.value(), self._dx.value())
    534         self._count_y = self._axis_count(
    535             self._ymax.value() - self._ymin.value(), self._dy.value())
    536         self._count_z = self._axis_count(
    537             self._zmax.value() - self._zmin.value(), self._dz.value())
    538         self._nx.setText('—' if self._count_x is None else str(self._count_x))
    539         self._ny.setText('—' if self._count_y is None else str(self._count_y))
    540         self._nz.setText('—' if self._count_z is None else str(self._count_z))
    541         self._update_summary()
    542 
    543     def _update_summary(self):
    544         if None in (self._count_x, self._count_y, self._count_z):
    545             self._summary_label.setText(
    546                 'Set a non-empty extent to compute the grid.'
    547             )
    548             return
    549         nx, ny, nz = self._count_x, self._count_y, self._count_z
    550         total = nx * ny * nz
    551         self._summary_label.setText(
    552             f'{nx} × {ny} × {nz} = {total:,} cells'
    553         )
    554 
    555     def options(self):
    556         bounds = (
    557             self._xmin.value(), self._xmax.value(),
    558             self._ymin.value(), self._ymax.value(),
    559             self._zmin.value(), self._zmax.value(),
    560         )
    561         grid = interp.GridSpec.from_cell_size(
    562             bounds,
    563             self._dx.value(),
    564             self._dy.value(),
    565             self._dz.value(),
    566         )
    567         radius = self._radius.value()
    568         radius_z = self._radius_z.value()
    569         max_neighbors = self._max_neighbors.value()
    570         return {
    571             'input_layers': self._selected_input_layers(),
    572             'doi_layers': self._selected_doi_layers(),
    573             'grid': grid,
    574             'power': self._power.value(),
    575             'log10': self._log10_checkbox.isChecked(),
    576             'clip_below_doi': self._clip_doi_checkbox.isChecked(),
    577             'radius': radius if radius > 0 else None,
    578             'radius_z': radius_z if radius_z > 0 else None,
    579             'min_neighbors': self._min_neighbors.value(),
    580             'max_neighbors': max_neighbors if max_neighbors > 0 else None,
    581         }
    582 
    583 
    584 def _layer_list(layers, wkb_type, preselect=None):
    585     widget = QListWidget()
    586     widget.setSelectionMode(QListWidget.SelectionMode.MultiSelection
    587                             if hasattr(QListWidget, 'SelectionMode')
    588                             else QListWidget.MultiSelection)
    589     for lyr in layers:
    590         if hasattr(lyr, 'wkbType') and lyr.wkbType() == wkb_type:
    591             item = QListWidgetItem(lyr.name())
    592             item.setData(32, lyr)
    593             widget.addItem(item)
    594             if preselect and preselect in lyr.name().lower():
    595                 item.setSelected(True)
    596     return widget
    597 
    598 
    599 def _selected_list_layers(widget):
    600     layers = []
    601     for i in range(widget.count()):
    602         item = widget.item(i)
    603         if item.isSelected() and item.data(32) is not None:
    604             layers.append(item.data(32))
    605     return layers
    606 
    607 
    608 class _MeanResistivityDialog(QDialog):
    609     def __init__(self, parent=None):
    610         super().__init__(parent)
    611         self.setWindowTitle('Mean Resistivity Maps')
    612 
    613         all_layers = list(QgsProject.instance().mapLayers().values())
    614         self._input_list = _layer_list(all_layers, Qgis.WkbType.LineStringZ)
    615         self._input_list.itemSelectionChanged.connect(self._update_summary)
    616         self._doi_list = _layer_list(all_layers, Qgis.WkbType.PointZ, 'doi')
    617 
    618         self._top = QDoubleSpinBox()
    619         self._bottom = QDoubleSpinBox()
    620         for sb in (self._top, self._bottom):
    621             sb.setRange(-1e5, 1e5)
    622             sb.setDecimals(1)
    623             sb.setSuffix(' m')
    624         self._top.setValue(50.0)
    625         self._bottom.setValue(-50.0)
    626 
    627         self._thickness = QDoubleSpinBox()
    628         self._thickness.setRange(0.1, 1e4)
    629         self._thickness.setDecimals(1)
    630         self._thickness.setSuffix(' m')
    631         self._thickness.setValue(10.0)
    632 
    633         self._cell_size = QDoubleSpinBox()
    634         self._cell_size.setRange(0.1, 1e5)
    635         self._cell_size.setDecimals(1)
    636         self._cell_size.setSuffix(' m')
    637         self._cell_size.setValue(50.0)
    638 
    639         self._radius = QDoubleSpinBox()
    640         self._radius.setRange(0.0, 1e6)
    641         self._radius.setDecimals(1)
    642         self._radius.setValue(0.0)
    643         self._radius.setSpecialValueText('Auto')
    644 
    645         self._power = QDoubleSpinBox()
    646         self._power.setRange(0.1, 10.0)
    647         self._power.setDecimals(1)
    648         self._power.setValue(2.0)
    649 
    650         self._min_neighbors = QSpinBox()
    651         self._min_neighbors.setRange(1, 1000)
    652         self._min_neighbors.setValue(1)
    653 
    654         self._min_coverage = QSpinBox()
    655         self._min_coverage.setRange(0, 100)
    656         self._min_coverage.setSuffix('%')
    657         self._min_coverage.setValue(50)
    658 
    659         self._summary_label = QLabel()
    660 
    661         for sb in (self._top, self._bottom, self._thickness, self._cell_size):
    662             sb.valueChanged.connect(self._update_summary)
    663 
    664         use_range_btn = QPushButton(
    665             'Use elevation range of selected input layers'
    666         )
    667         use_range_btn.clicked.connect(self._fill_range_from_inputs)
    668 
    669         buttons = QDialogButtonBox(_dialog_buttons())
    670         buttons.accepted.connect(self.accept)
    671         buttons.rejected.connect(self.reject)
    672 
    673         interval_form = QFormLayout()
    674         interval_form.addRow('Top elevation', self._top)
    675         interval_form.addRow('Bottom elevation', self._bottom)
    676         interval_form.addRow('Interval thickness', self._thickness)
    677 
    678         param_form = QFormLayout()
    679         param_form.addRow('Cell size', self._cell_size)
    680         param_form.addRow('Search radius (m)', self._radius)
    681         param_form.addRow('IDW power', self._power)
    682         param_form.addRow('Min neighbors', self._min_neighbors)
    683         param_form.addRow('Minimum interval coverage', self._min_coverage)
    684         param_form.addRow('', self._summary_label)
    685 
    686         layout = QVBoxLayout()
    687         layout.addWidget(QLabel('Input layers (LineStringZ):'))
    688         layout.addWidget(self._input_list)
    689         layout.addWidget(QLabel('DOI layers (PointZ, optional):'))
    690         layout.addWidget(self._doi_list)
    691         layout.addWidget(use_range_btn)
    692         layout.addLayout(interval_form)
    693         layout.addLayout(param_form)
    694         layout.addWidget(buttons)
    695         self.setLayout(layout)
    696 
    697         self._update_summary()
    698 
    699     def _fill_range_from_inputs(self):
    700         tops, bottoms = [], []
    701         for lyr in _selected_list_layers(self._input_list):
    702             fields = lyr.fields()
    703             for name, values, pick in (('ZTop', tops, lyr.maximumValue),
    704                                        ('ZBottom', bottoms, lyr.minimumValue)):
    705                 index = fields.indexOf(name)
    706                 if index < 0:
    707                     continue
    708                 value = pick(index)
    709                 if isinstance(value, (int, float)) and value == value:
    710                     values.append(float(value))
    711         if not tops or not bottoms:
    712             return
    713         step = self._thickness.value()
    714         self._top.setValue(math.ceil(max(tops) / step) * step)
    715         self._bottom.setValue(math.floor(min(bottoms) / step) * step)
    716 
    717     def _update_summary(self, *_args):
    718         try:
    719             intervals = mres.elevation_intervals(
    720                 self._top.value(), self._bottom.value(),
    721                 self._thickness.value(),
    722             )
    723         except ValueError as exc:
    724             self._summary_label.setText(str(exc).capitalize() + '.')
    725             return
    726         text = f'{len(intervals)} intervals'
    727         layers = _selected_list_layers(self._input_list)
    728         if layers:
    729             xs, ys = [], []
    730             for lyr in layers:
    731                 ext = lyr.extent()
    732                 xs += [ext.xMinimum(), ext.xMaximum()]
    733                 ys += [ext.yMinimum(), ext.yMaximum()]
    734             try:
    735                 grid = mres.map_grid(xs, ys, self._cell_size.value())
    736                 text += f', {grid.nx} × {grid.ny} cells'
    737             except ValueError as exc:
    738                 text += f'; {exc}'
    739         self._summary_label.setText(text)
    740 
    741     def options(self):
    742         radius = self._radius.value()
    743         return {
    744             'input_layers': _selected_list_layers(self._input_list),
    745             'doi_layers': _selected_list_layers(self._doi_list),
    746             'intervals': mres.elevation_intervals(
    747                 self._top.value(), self._bottom.value(),
    748                 self._thickness.value(),
    749             ),
    750             'cell_size': self._cell_size.value(),
    751             'radius': radius if radius > 0 else None,
    752             'power': self._power.value(),
    753             'min_neighbors': self._min_neighbors.value(),
    754             'min_coverage': self._min_coverage.value() / 100.0,
    755         }
    756 
    757 
    758 class TEMLoaderPlugin:
    759     def __init__(self, iface):
    760         self.iface = iface
    761         self._action = None
    762         self._interp_action = None
    763         self._mean_res_action = None
    764 
    765     def initGui(self):
    766         self._action = QAction('Load TEM XYZ files\u2026', self.iface.mainWindow())
    767         self._action.triggered.connect(self.run)
    768         self.iface.addPluginToMenu('TEM Loader', self._action)
    769 
    770         self._interp_action = QAction('3D interpolation\u2026', self.iface.mainWindow())
    771         self._interp_action.triggered.connect(self.run_interpolation)
    772         self.iface.addPluginToMenu('TEM Loader', self._interp_action)
    773 
    774         self._mean_res_action = QAction(
    775             'Mean resistivity maps\u2026', self.iface.mainWindow()
    776         )
    777         self._mean_res_action.triggered.connect(self.run_mean_resistivity_maps)
    778         self.iface.addPluginToMenu('TEM Loader', self._mean_res_action)
    779 
    780     def unload(self):
    781         self.iface.removePluginMenu('TEM Loader', self._action)
    782         self._action = None
    783         if self._interp_action is not None:
    784             self.iface.removePluginMenu('TEM Loader', self._interp_action)
    785             self._interp_action = None
    786         if self._mean_res_action is not None:
    787             self.iface.removePluginMenu('TEM Loader', self._mean_res_action)
    788             self._mean_res_action = None
    789 
    790     def run(self):
    791         paths, _ = QFileDialog.getOpenFileNames(
    792             self.iface.mainWindow(),
    793             'Select TEM XYZ files',
    794             '',
    795             'XYZ files (*.xyz);;All files (*)',
    796         )
    797         if not paths:
    798             return
    799 
    800         dialog = _ImportOptionsDialog(self.iface.mainWindow())
    801         if _exec_dialog(dialog) != _dialog_code('Accepted'):
    802             return
    803 
    804         options = dialog.options()
    805         failed = []
    806         for path in paths:
    807             filepath = Path(path)
    808             try:
    809                 self._load_xyz(filepath, **options)
    810             except Exception as exc:
    811                 failed.append(f'{filepath.name}: {exc}')
    812         if failed:
    813             QMessageBox.warning(
    814                 self.iface.mainWindow(),
    815                 'TEM Loader',
    816                 '\n'.join(failed),
    817             )
    818 
    819     def _resolve_crs(self, filepath, project):
    820         crs = None
    821         source_epsg = core.detect_source_epsg(filepath)
    822         if source_epsg:
    823             candidate = QgsCoordinateReferenceSystem()
    824             if candidate.createFromString(source_epsg) and candidate.isValid():
    825                 crs = candidate
    826 
    827         if crs is None:
    828             crs = project.crs()
    829         if not crs.isValid():
    830             crs = QgsCoordinateReferenceSystem()
    831             crs.createFromString('EPSG:4326')
    832         return crs
    833 
    834     def run_interpolation(self):
    835         dialog = _InterpolationDialog(self.iface.mainWindow())
    836         if _exec_dialog(dialog) != _dialog_code('Accepted'):
    837             return
    838 
    839         try:
    840             opts = dialog.options()
    841         except ValueError as exc:
    842             QMessageBox.warning(
    843                 self.iface.mainWindow(),
    844                 'TEM Loader',
    845                 f'Invalid grid: {exc}. Set a non-empty extent (e.g. use '
    846                 '"Use extent of selected input layers").',
    847             )
    848             return
    849         input_layers = opts['input_layers']
    850         if not input_layers:
    851             QMessageBox.warning(
    852                 self.iface.mainWindow(),
    853                 'TEM Loader',
    854                 'No input layers selected.',
    855             )
    856             return
    857 
    858         gpkg_path = _output_path(input_layers, '_voxels.gpkg')
    859         if gpkg_path is None:
    860             QMessageBox.warning(
    861                 self.iface.mainWindow(),
    862                 'TEM Loader',
    863                 'Could not determine an output location: the selected input '
    864                 'layers are not backed by a GeoPackage on disk.',
    865             )
    866             return
    867 
    868         try:
    869             self._run_interpolation(gpkg_path, opts, input_layers)
    870         except ValueError as exc:
    871             QMessageBox.warning(
    872                 self.iface.mainWindow(),
    873                 'TEM Loader',
    874                 str(exc),
    875             )
    876 
    877     def _run_interpolation(self, gpkg_path, opts, input_layers):
    878         required = ('X', 'Y', 'ZMid', 'Resistivity')
    879         for lyr in input_layers:
    880             names = {field.name() for field in lyr.fields()}
    881             missing = [name for name in required if name not in names]
    882             if missing:
    883                 raise ValueError(
    884                     f'Layer "{lyr.name()}" is missing required field(s): '
    885                     f'{", ".join(missing)}. Select TEM "layers" outputs.'
    886                 )
    887 
    888         layer_rows = []
    889         for lyr in input_layers:
    890             for feat in lyr.getFeatures():
    891                 attrs = {field.name(): feat[field.name()] for field in lyr.fields()}
    892                 geom = feat.geometry()
    893                 layer_rows.append({
    894                     'X': attrs.get('X'),
    895                     'Y': attrs.get('Y'),
    896                     'ZMid': attrs.get('ZMid'),
    897                     'Resistivity': attrs.get('Resistivity'),
    898                     'Geometry': geom.asWkt() if geom else '',
    899                 })
    900 
    901         doi_rows = []
    902         for lyr in opts['doi_layers']:
    903             for feat in lyr.getFeatures():
    904                 attrs = {field.name(): feat[field.name()] for field in lyr.fields()}
    905                 doi_rows.append({
    906                     'X': attrs.get('X'),
    907                     'Y': attrs.get('Y'),
    908                     'ZDOI': attrs.get('ZDOI'),
    909                 })
    910 
    911         progress_dialog = QProgressDialog(
    912             'Interpolating voxels…', 'Cancel', 0, 100,
    913             self.iface.mainWindow(),
    914         )
    915         progress_dialog.setWindowTitle('TEM Loader')
    916         progress_dialog.setMinimumDuration(500)
    917 
    918         last_pct = [-1]
    919 
    920         def on_progress(done, total):
    921             pct = int(100 * done / total) if total else 100
    922             # Only touch the widget on whole-percent changes; the callback fires
    923             # once per cell (up to several million times).
    924             if pct != last_pct[0]:
    925                 last_pct[0] = pct
    926                 progress_dialog.setValue(pct)
    927             return progress_dialog.wasCanceled()
    928 
    929         grid = opts['grid']
    930         voxels = interp.interpolate_voxels(
    931             layer_rows,
    932             grid,
    933             doi_points=doi_rows if doi_rows else None,
    934             clip_below_doi=opts['clip_below_doi'],
    935             power=opts['power'],
    936             radius=opts['radius'],
    937             radius_z=opts['radius_z'],
    938             min_neighbors=opts['min_neighbors'],
    939             max_neighbors=opts['max_neighbors'],
    940             log10=opts['log10'],
    941             progress=on_progress,
    942         )
    943         progress_dialog.setValue(100)
    944 
    945         if progress_dialog.wasCanceled():
    946             raise ValueError('Interpolation cancelled.')
    947 
    948         if not voxels:
    949             raise ValueError('Interpolation produced no voxels. Check extent and input layers.')
    950 
    951         project = QgsProject.instance()
    952         crs = input_layers[0].crs()
    953         transform_context = project.transformContext()
    954 
    955         _write_geopackage_layer(
    956             voxels,
    957             gpkg_path,
    958             'voxels',
    959             Qgis.WkbType.PointZ,
    960             crs,
    961             transform_context,
    962             QgsVectorFileWriter.CreateOrOverwriteFile,
    963         )
    964 
    965         uri = _build_geopackage_uri(gpkg_path, 'voxels')
    966         layer = QgsVectorLayer(uri, 'voxels', 'ogr')
    967         if not layer.isValid():
    968             raise ValueError(f'Failed to load voxel layer from {gpkg_path}')
    969 
    970         qml = STYLES_DIR / 'voxels.qml'
    971         if qml.exists():
    972             layer.loadNamedStyle(str(qml))
    973 
    974         project.addMapLayer(layer, False)
    975         root = project.layerTreeRoot()
    976         group = root.insertGroup(0, 'Interpolation')
    977         group.insertLayer(0, layer)
    978 
    979         self.iface.messageBar().pushInfo(
    980             'TEM Loader', f'Voxel layer saved to {gpkg_path}'
    981         )
    982 
    983     def run_mean_resistivity_maps(self):
    984         dialog = _MeanResistivityDialog(self.iface.mainWindow())
    985         if _exec_dialog(dialog) != _dialog_code('Accepted'):
    986             return
    987         try:
    988             self._run_mean_resistivity_maps(dialog.options())
    989         except ValueError as exc:
    990             QMessageBox.warning(self.iface.mainWindow(), 'TEM Loader', str(exc))
    991 
    992     def _run_mean_resistivity_maps(self, opts):
    993         input_layers = opts['input_layers']
    994         if not input_layers:
    995             raise ValueError('No input layers selected.')
    996         required = ('X', 'Y', 'ZTop', 'ZBottom', 'Resistivity')
    997         for lyr in input_layers:
    998             names = {field.name() for field in lyr.fields()}
    999             missing = [name for name in required if name not in names]
   1000             if missing:
   1001                 raise ValueError(
   1002                     f'Layer "{lyr.name()}" is missing required field(s): '
   1003                     f'{", ".join(missing)}. Select TEM "layers" outputs.'
   1004                 )
   1005         out_dir = _output_path(input_layers, '_mean_resistivity')
   1006         if out_dir is None:
   1007             raise ValueError(
   1008                 'Could not determine an output location: the selected input '
   1009                 'layers are not backed by a GeoPackage on disk.'
   1010             )
   1011 
   1012         layer_rows = [
   1013             {name: feat[name] for name in required}
   1014             for lyr in input_layers for feat in lyr.getFeatures()
   1015         ]
   1016         doi_rows = [
   1017             {name: feat[name] for name in ('X', 'Y', 'ZDOI')}
   1018             for lyr in opts['doi_layers'] for feat in lyr.getFeatures()
   1019         ]
   1020 
   1021         progress_dialog = QProgressDialog(
   1022             'Computing mean resistivity maps…', 'Cancel', 0, 100,
   1023             self.iface.mainWindow(),
   1024         )
   1025         progress_dialog.setWindowTitle('TEM Loader')
   1026         progress_dialog.setMinimumDuration(500)
   1027 
   1028         def on_progress(done, total):
   1029             progress_dialog.setValue(int(100 * done / total))
   1030             return progress_dialog.wasCanceled()
   1031 
   1032         grid, maps = mres.mean_resistivity_maps(
   1033             layer_rows,
   1034             opts['intervals'],
   1035             opts['cell_size'],
   1036             doi_points=doi_rows or None,
   1037             power=opts['power'],
   1038             radius=opts['radius'],
   1039             min_neighbors=opts['min_neighbors'],
   1040             min_coverage=opts['min_coverage'],
   1041             progress=on_progress,
   1042         )
   1043         progress_dialog.setValue(100)
   1044         if progress_dialog.wasCanceled():
   1045             raise ValueError('Mean resistivity maps cancelled.')
   1046         if not maps:
   1047             raise ValueError(
   1048                 'No mean resistivity maps produced. Check the elevation range '
   1049                 'and input layers.'
   1050             )
   1051 
   1052         out_dir.mkdir(exist_ok=True)
   1053         crs_wkt = input_layers[0].crs().toWkt()
   1054         paths = []
   1055         for top, bottom, array, _ in maps:
   1056             path = out_dir / _mean_resistivity_file_name(top, bottom)
   1057             write_geotiff(path, grid, array, crs_wkt)
   1058             paths.append((path, _mean_resistivity_layer_name(top, bottom)))
   1059 
   1060         project = QgsProject.instance()
   1061         group = project.layerTreeRoot().insertGroup(0, 'Mean resistivity')
   1062         qml = STYLES_DIR / 'mean_resistivity.qml'
   1063         for index, (path, name) in enumerate(paths):
   1064             layer = QgsRasterLayer(str(path), name, 'gdal')
   1065             if not layer.isValid():
   1066                 raise ValueError(f'Failed to load mean resistivity map {path}')
   1067             layer.loadNamedStyle(str(qml))
   1068             project.addMapLayer(layer, False)
   1069             node = group.insertLayer(index, layer)
   1070             if index > 0:
   1071                 node.setItemVisibilityChecked(False)
   1072 
   1073         self.iface.messageBar().pushInfo(
   1074             'TEM Loader',
   1075             f'{len(paths)} mean resistivity maps saved to {out_dir}',
   1076         )
   1077 
   1078     def _load_xyz(
   1079         self,
   1080         filepath,
   1081         clip_below_doi=True,
   1082         mask_below_doi=True,
   1083         below_doi_opacity=core.BELOW_DOI_OPACITY,
   1084         elevation_raster_layer=None,
   1085     ):
   1086         points, doi_points, layers = core.process_xyz(
   1087             filepath,
   1088             mask_below_doi=mask_below_doi,
   1089             below_doi_opacity=below_doi_opacity,
   1090             clip_below_doi=clip_below_doi,
   1091         )
   1092 
   1093         project = QgsProject.instance()
   1094         crs = self._resolve_crs(filepath, project)
   1095         _adjust_rows_to_dem(
   1096             points,
   1097             doi_points,
   1098             layers,
   1099             elevation_raster_layer,
   1100             crs,
   1101             project,
   1102         )
   1103         gpkg_path = filepath.with_suffix('.gpkg')
   1104         transform_context = project.transformContext()
   1105 
   1106         output_layers = [
   1107             ('layers', layers, Qgis.WkbType.LineStringZ),
   1108         ]
   1109         if doi_points:
   1110             output_layers.append(('doi', doi_points, Qgis.WkbType.PointZ))
   1111         output_layers.append(('points', points, Qgis.WkbType.PointZ))
   1112 
   1113         written_layer_names = []
   1114         first_layer = True
   1115         for name, rows, wkb_type in output_layers:
   1116             if not rows:
   1117                 continue
   1118             action = (
   1119                 QgsVectorFileWriter.CreateOrOverwriteFile
   1120                 if first_layer
   1121                 else QgsVectorFileWriter.CreateOrOverwriteLayer
   1122             )
   1123             _write_geopackage_layer(
   1124                 rows,
   1125                 gpkg_path,
   1126                 name,
   1127                 wkb_type,
   1128                 crs,
   1129                 transform_context,
   1130                 action,
   1131             )
   1132             written_layer_names.append(name)
   1133             first_layer = False
   1134 
   1135         loaded_layers = {}
   1136         for name in written_layer_names:
   1137             uri = _build_geopackage_uri(gpkg_path, name)
   1138             layer = QgsVectorLayer(uri, name, 'ogr')
   1139             if not layer.isValid():
   1140                 continue
   1141 
   1142             qml = STYLES_DIR / f'{name}.qml'
   1143             if qml.exists():
   1144                 layer.loadNamedStyle(str(qml))
   1145 
   1146             project.addMapLayer(layer, False)
   1147             loaded_layers[name] = layer
   1148 
   1149         if not loaded_layers:
   1150             raise ValueError('failed to load any layers')
   1151 
   1152         group_name = filepath.stem
   1153         root = project.layerTreeRoot()
   1154         group = root.insertGroup(0, group_name)
   1155         insert_index = 0
   1156         for name in ('points', 'doi', 'layers'):
   1157             layer = loaded_layers.get(name)
   1158             if layer is None:
   1159                 continue
   1160             group.insertLayer(insert_index, layer)
   1161             insert_index += 1
   1162 
   1163         failed = [name for name in written_layer_names if name not in loaded_layers]
   1164         if failed:
   1165             QMessageBox.warning(
   1166                 self.iface.mainWindow(),
   1167                 'TEM Loader',
   1168                 f'{filepath.name}: failed to load layers: {", ".join(failed)}',
   1169             )