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 )