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 ed8f314d35f4a5b5dca614ae288f5deebcd44985
parent 186288b4ebca621edac6402597fe630273ab27be
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Thu, 28 May 2026 18:44:36 +0200

feat(qgis): hide layers below DOI by default

Diffstat:
Mtem_loader/core.py | 44+++++++++++++++++++++++++++++++++++++++++---
Mtem_loader/tem_loader.py | 24+++++++++++++++++++++---
Mtest/test_core.py | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
3 files changed, 188 insertions(+), 25 deletions(-)

diff --git a/tem_loader/core.py b/tem_loader/core.py @@ -194,7 +194,29 @@ def count_valid_layers(row, res_cols, thick_cols): return count -def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY): +def _clip_depth_to_doi(depth_top, depth_bottom, doi, clip_below_doi): + if not clip_below_doi: + return depth_bottom, False + if doi is None: + return depth_bottom, False + try: + if math.isnan(doi): + return depth_bottom, False + except TypeError: + return depth_bottom, False + if depth_top >= doi: + return depth_bottom, True + if depth_bottom > doi: + return doi, False + return depth_bottom, False + + +def process_xyz( + path, + mask_below_doi=True, + below_doi_opacity=BELOW_DOI_OPACITY, + clip_below_doi=True, +): below_doi_opacity = validate_opacity(below_doi_opacity) points = [] @@ -255,6 +277,7 @@ def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY): layers, mask_below_doi, below_doi_opacity, + clip_below_doi, ) continue @@ -333,11 +356,18 @@ def process_xyz(path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY): depth_top = cum_depth depth_bottom = cum_depth + thick + cum_depth = depth_bottom + clipped_bottom, skip = _clip_depth_to_doi( + depth_top, depth_bottom, doi, clip_below_doi, + ) + if skip: + continue + depth_bottom = clipped_bottom + depth_mid = (depth_top + depth_bottom) / 2 z_top = z - depth_top z_bot = z - depth_bottom z_mid = (z_top + z_bot) / 2 - cum_depth = depth_bottom layer_wkt = f'LINESTRING Z ({x} {y} {z_top}, {x} {y} {z_bot})' layers.append({ @@ -376,6 +406,7 @@ def _append_atem_sci_row( layers, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY, + clip_below_doi=True, ): x = float(row['E']) y = float(row['N']) @@ -405,9 +436,16 @@ def _append_atem_sci_row( else: break z_top = z_tops[i] - z_mid = (z_top + z_bot) / 2 depth_top = z - z_top depth_bottom = z - z_bot + clipped_bottom, skip = _clip_depth_to_doi( + depth_top, depth_bottom, doi, clip_below_doi, + ) + if skip: + continue + depth_bottom = clipped_bottom + z_bot = z - depth_bottom + z_mid = (z_top + z_bot) / 2 depth_mid = (depth_top + depth_bottom) / 2 layer_rows.append({ 'X': x, diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py @@ -220,8 +220,13 @@ class _ImportOptionsDialog(QDialog): super().__init__(parent) self.setWindowTitle('TEM Loader Options') + self._clip_checkbox = QCheckBox( + 'Hide layers below depth of interest (DOI, 2D and 3D views)' + ) + self._clip_checkbox.setChecked(True) + self._mask_checkbox = QCheckBox( - 'Mask out layers below depth of interest (DOI)' + 'Make layers below DOI partially transparent (2D view only)' ) self._mask_checkbox.setChecked(True) @@ -229,8 +234,10 @@ class _ImportOptionsDialog(QDialog): self._opacity_spinbox.setRange(0, 100) self._opacity_spinbox.setSuffix('%') self._opacity_spinbox.setValue(core.BELOW_DOI_OPACITY) - self._opacity_spinbox.setEnabled(self._mask_checkbox.isChecked()) - self._mask_checkbox.toggled.connect(self._opacity_spinbox.setEnabled) + + self._clip_checkbox.toggled.connect(self._update_below_doi_controls) + self._mask_checkbox.toggled.connect(self._update_below_doi_controls) + self._update_below_doi_controls() self._dem_checkbox = QCheckBox( 'Adjust vertical position to digital elevation model' @@ -255,6 +262,7 @@ class _ImportOptionsDialog(QDialog): buttons.rejected.connect(self.reject) layout = QVBoxLayout() + layout.addWidget(self._clip_checkbox) layout.addWidget(self._mask_checkbox) layout.addLayout(opacity_form) layout.addWidget(self._dem_checkbox) @@ -262,11 +270,19 @@ class _ImportOptionsDialog(QDialog): layout.addWidget(buttons) self.setLayout(layout) + def _update_below_doi_controls(self, *_args): + clipping = self._clip_checkbox.isChecked() + self._mask_checkbox.setEnabled(not clipping) + self._opacity_spinbox.setEnabled( + not clipping and self._mask_checkbox.isChecked() + ) + def options(self): elevation_raster_layer = None if self._dem_checkbox.isChecked(): elevation_raster_layer = self._dem_raster_combo.currentLayer() return { + 'clip_below_doi': self._clip_checkbox.isChecked(), 'mask_below_doi': self._mask_checkbox.isChecked(), 'below_doi_opacity': self._opacity_spinbox.value(), 'elevation_raster_layer': elevation_raster_layer, @@ -341,6 +357,7 @@ class TEMLoaderPlugin: def _load_xyz( self, filepath, + clip_below_doi=True, mask_below_doi=True, below_doi_opacity=core.BELOW_DOI_OPACITY, elevation_raster_layer=None, @@ -349,6 +366,7 @@ class TEMLoaderPlugin: filepath, mask_below_doi=mask_below_doi, below_doi_opacity=below_doi_opacity, + clip_below_doi=clip_below_doi, ) project = QgsProject.instance() diff --git a/test/test_core.py b/test/test_core.py @@ -43,7 +43,7 @@ class MetadataTests(unittest.TestCase): class ProcessXYZTests(unittest.TestCase): def test_temimage_4_0_4_6_fixture(self): path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 166) self.assertEqual(len(doi_points), 166) @@ -54,7 +54,7 @@ class ProcessXYZTests(unittest.TestCase): def test_temimage_4_0_7_8_fixture(self): path = FIXTURE_DIR / "profiler_temimager_4_0_7_8.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 44) self.assertEqual(len(doi_points), 44) @@ -64,7 +64,7 @@ class ProcessXYZTests(unittest.TestCase): def test_stem_temimage_4_0_4_6_fixture(self): path = FIXTURE_DIR / "stem_temimager_4_0_4_6.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 14) self.assertEqual(len(doi_points), 14) @@ -74,7 +74,7 @@ class ProcessXYZTests(unittest.TestCase): def test_aarhus_workbench_fixture(self): path = FIXTURE_DIR / "stem_workbench_2026_1.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 158) self.assertEqual(len(doi_points), 158) @@ -89,7 +89,7 @@ class ProcessXYZTests(unittest.TestCase): def test_aarhus_workbench_2024_2_0_0_stem_40x40_fixture(self): path = FIXTURE_DIR / "stem_40x40_workbench_2024_2_0_0.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 20) self.assertEqual(len(doi_points), 20) @@ -105,7 +105,7 @@ class ProcessXYZTests(unittest.TestCase): def test_aarhus_workbench_2024_2_0_0_stem_80x80_fixture(self): path = FIXTURE_DIR / "stem_80x80_workbench_2024_2_0_0.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 3) self.assertEqual(len(doi_points), 3) @@ -121,7 +121,7 @@ class ProcessXYZTests(unittest.TestCase): def test_aarhus_workbench_2024_2_0_0_stem_profiler_fixture(self): path = FIXTURE_DIR / "stem_profiler_workbench_2024_2_0_0.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 274) self.assertEqual(len(doi_points), 274) @@ -152,7 +152,7 @@ class ProcessXYZTests(unittest.TestCase): def test_atem_sci_workbench_fixture(self): path = FIXTURE_DIR / "atem_sci_workbench.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(len(points), 49) self.assertEqual(len(doi_points), 49) @@ -177,11 +177,12 @@ class ProcessXYZTests(unittest.TestCase): def test_process_xyz_accepts_default_masking_options(self): path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" - _, _, default_layers = process_xyz(path) + _, _, default_layers = process_xyz(path, clip_below_doi=False) _, _, option_layers = process_xyz( path, mask_below_doi=True, below_doi_opacity=BELOW_DOI_OPACITY, + clip_below_doi=False, ) self.assertEqual(len(option_layers), len(default_layers)) @@ -198,13 +199,17 @@ class ProcessXYZTests(unittest.TestCase): def test_process_xyz_can_disable_below_doi_mask(self): path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" - _, _, layers = process_xyz(path, mask_below_doi=False) + _, _, layers = process_xyz( + path, mask_below_doi=False, clip_below_doi=False, + ) self.assertTrue(all(row["Opacity"] == ABOVE_DOI_OPACITY for row in layers)) def test_atem_sci_process_xyz_uses_custom_below_doi_opacity(self): path = FIXTURE_DIR / "atem_sci_workbench.xyz" - _, _, layers = process_xyz(path, below_doi_opacity=25) + _, _, layers = process_xyz( + path, below_doi_opacity=25, clip_below_doi=False, + ) opacities = {row["Opacity"] for row in layers} self.assertIn(ABOVE_DOI_OPACITY, opacities) @@ -245,7 +250,7 @@ class ProcessXYZTests(unittest.TestCase): "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" ) - _, _, layers = process_xyz(path) + _, _, layers = process_xyz(path, clip_below_doi=False) self.assertEqual(layers[0]["Opacity"], ABOVE_DOI_OPACITY) self.assertEqual(layers[1]["DepthTop"], 10.0) @@ -257,7 +262,7 @@ class ProcessXYZTests(unittest.TestCase): # TEMImage fixture has DOI, so opacity depends on midpoint depth vs DOI. # Each sounding has its own DOI; we verify the first sounding's layers. path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz" - points, doi_points, layers = process_xyz(path) + points, doi_points, layers = process_xyz(path, clip_below_doi=False) self.assertTrue(all("Opacity" in row for row in layers)) # Every layer's opacity is one of the two valid values self.assertTrue( @@ -276,6 +281,84 @@ class ProcessXYZTests(unittest.TestCase): f"Layer {layer['Layer']} midpoint depth {depth_mid} vs DOI {first_doi}", ) + def test_clip_below_doi_drops_layers_fully_below_doi(self): + with TemporaryDirectory() as tmp: + path = Path(tmp) / "clip.xyz" + path.write_text( + "/ Line StationNo X Y Z DOI DataResidual NumLayers " + "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" + "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" + ) + + _, _, layers = process_xyz(path, clip_below_doi=True) + + self.assertEqual(len(layers), 2) + self.assertEqual(layers[0]["DepthTop"], 0.0) + self.assertEqual(layers[0]["DepthBottom"], 10.0) + self.assertEqual(layers[1]["DepthTop"], 10.0) + self.assertEqual(layers[1]["DepthBottom"], 15.0) + self.assertEqual(layers[1]["ZBottom"], 85.0) + self.assertIn( + "LINESTRING Z (0.0 0.0 90.0, 0.0 0.0 85.0)", + layers[1]["Geometry"], + ) + + def test_clip_below_doi_keeps_layer_when_doi_at_layer_bottom(self): + with TemporaryDirectory() as tmp: + path = Path(tmp) / "boundary.xyz" + path.write_text( + "/ Line StationNo X Y Z DOI DataResidual NumLayers " + "Res_001 Res_002 Thick_001 Thick_002\n" + "1 1 0 0 100 10 0 2 10 20 10 10\n" + ) + + _, _, layers = process_xyz(path, clip_below_doi=True) + + self.assertEqual(len(layers), 1) + self.assertEqual(layers[0]["DepthTop"], 0.0) + self.assertEqual(layers[0]["DepthBottom"], 10.0) + + def test_clip_below_doi_is_noop_when_disabled(self): + with TemporaryDirectory() as tmp: + path = Path(tmp) / "unclipped.xyz" + path.write_text( + "/ Line StationNo X Y Z DOI DataResidual NumLayers " + "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" + "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" + ) + + _, _, layers = process_xyz(path, clip_below_doi=False) + + self.assertEqual(len(layers), 3) + self.assertEqual(layers[-1]["DepthBottom"], 30.0) + + def test_clip_below_doi_default_is_enabled(self): + with TemporaryDirectory() as tmp: + path = Path(tmp) / "default.xyz" + path.write_text( + "/ Line StationNo X Y Z DOI DataResidual NumLayers " + "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n" + "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n" + ) + + _, _, layers = process_xyz(path) + + self.assertEqual(len(layers), 2) + + def test_clip_below_doi_skips_sci_format_safely(self): + path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" + _, _, layers_default = process_xyz(path) + _, _, layers_unclipped = process_xyz(path, clip_below_doi=False) + + self.assertEqual(len(layers_default), len(layers_unclipped)) + + def test_clip_below_doi_clips_atem_sci_layers(self): + path = FIXTURE_DIR / "atem_sci_workbench.xyz" + _, _, unclipped = process_xyz(path, clip_below_doi=False) + _, _, clipped = process_xyz(path, clip_below_doi=True) + + self.assertLess(len(clipped), len(unclipped)) + def test_sci_fixture_layers_all_above_opacity(self): # SCI format has no DOI, so all layers get ABOVE_DOI_OPACITY path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" @@ -494,6 +577,7 @@ class PluginTests(unittest.TestCase): def __init__(self, text): self.text = text self._checked = False + self.enabled = True self.toggled = FakeSignal() def setChecked(self, checked): @@ -503,6 +587,9 @@ class PluginTests(unittest.TestCase): if changed: self.toggled.emit(checked) + def setEnabled(self, enabled): + self.enabled = bool(enabled) + def isChecked(self): return self._checked @@ -881,6 +968,7 @@ class PluginTests(unittest.TestCase): iface.mainWindow.return_value = object() dialog = Mock() dialog.options.return_value = { + "clip_below_doi": True, "mask_below_doi": True, "below_doi_opacity": 35, "elevation_raster_layer": None, @@ -914,6 +1002,7 @@ class PluginTests(unittest.TestCase): dialog = Mock() raster_layer = object() dialog.options.return_value = { + "clip_below_doi": True, "mask_below_doi": True, "below_doi_opacity": 35, "elevation_raster_layer": raster_layer, @@ -929,6 +1018,7 @@ class PluginTests(unittest.TestCase): module._exec_dialog.assert_called_once_with(dialog) plugin._load_xyz.assert_called_once_with( Path("/tmp/model.xyz"), + clip_below_doi=True, mask_below_doi=True, below_doi_opacity=35, elevation_raster_layer=raster_layer, @@ -957,8 +1047,13 @@ class PluginTests(unittest.TestCase): self.assertEqual(dialog.title, "TEM Loader Options") self.assertEqual( + dialog._clip_checkbox.text, + "Hide layers below depth of interest (DOI, 2D and 3D views)", + ) + self.assertTrue(dialog._clip_checkbox.isChecked()) + self.assertEqual( dialog._mask_checkbox.text, - "Mask out layers below depth of interest (DOI)", + "Make layers below DOI partially transparent (2D view only)", ) self.assertEqual( dialog._dem_checkbox.text, @@ -968,26 +1063,33 @@ class PluginTests(unittest.TestCase): self.assertEqual(dialog._opacity_spinbox.minimum, 0) self.assertEqual(dialog._opacity_spinbox.maximum, 100) self.assertEqual(dialog._opacity_spinbox.suffix, "%") - self.assertTrue(dialog._opacity_spinbox.enabled) - self.assertIs(dialog.layout.items[0], dialog._mask_checkbox) + self.assertFalse(dialog._mask_checkbox.enabled) + self.assertFalse(dialog._opacity_spinbox.enabled) + self.assertIs(dialog.layout.items[0], dialog._clip_checkbox) + self.assertIs(dialog.layout.items[1], dialog._mask_checkbox) self.assertEqual( - dialog.layout.items[1].rows, + dialog.layout.items[2].rows, [("Opacity", dialog._opacity_spinbox)], ) - self.assertIs(dialog.layout.items[2], dialog._dem_checkbox) + self.assertIs(dialog.layout.items[3], dialog._dem_checkbox) self.assertEqual( - dialog.layout.items[3].rows, + dialog.layout.items[4].rows, [("Elevation raster", dialog._dem_raster_combo)], ) self.assertEqual( dialog.options(), { + "clip_below_doi": True, "mask_below_doi": True, "below_doi_opacity": module.core.BELOW_DOI_OPACITY, "elevation_raster_layer": None, }, ) + dialog._clip_checkbox.setChecked(False) + self.assertTrue(dialog._mask_checkbox.enabled) + self.assertTrue(dialog._opacity_spinbox.enabled) + dialog._opacity_spinbox.setValue(35) dialog._mask_checkbox.setChecked(False) @@ -995,6 +1097,7 @@ class PluginTests(unittest.TestCase): self.assertEqual( dialog.options(), { + "clip_below_doi": False, "mask_below_doi": False, "below_doi_opacity": 35, "elevation_raster_layer": None, @@ -1056,6 +1159,7 @@ class PluginTests(unittest.TestCase): iface.mainWindow.return_value = object() dialog = Mock() dialog.options.return_value = { + "clip_below_doi": True, "mask_below_doi": True, "below_doi_opacity": 35, "elevation_raster_layer": None, @@ -1070,6 +1174,7 @@ class PluginTests(unittest.TestCase): self.assertFalse(hasattr(module.QDialog, "Accepted")) plugin._load_xyz.assert_called_once_with( Path("/tmp/model.xyz"), + clip_below_doi=True, mask_below_doi=True, below_doi_opacity=35, elevation_raster_layer=None, @@ -1638,6 +1743,7 @@ class PluginTests(unittest.TestCase): Path("/tmp/model.xyz"), mask_below_doi=False, below_doi_opacity=35, + clip_below_doi=True, ) self.assertFalse(module.core.write_csv.called) self.assertEqual( @@ -1742,6 +1848,7 @@ class PluginTests(unittest.TestCase): Path("/tmp/model.xyz"), mask_below_doi=False, below_doi_opacity=35, + clip_below_doi=True, ) self.assertEqual( module.QgsVectorFileWriter.created[0].features[0].geometry.wkt,