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 ceefff40dade5965c9d48cc8e43a8cdec1702b87
parent 5398f115e1bb370afbd22e3812182ecc60d07e5a
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Sun, 10 May 2026 00:10:28 +0200

feat(qgis): load imported layers from GeoPackage

Diffstat:
Mtem_loader/tem_loader.py | 73+++++++++++++++++++++++++++++++++++++++++++------------------------------
Mtest/test_core.py | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 258 insertions(+), 35 deletions(-)

diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py @@ -96,18 +96,6 @@ def _write_geopackage_layer(rows, gpkg_path, layer_name, wkb_type, crs, del writer -def _build_delimited_text_uri(csv_path, geom_type, crs_str): - base_uri = csv_path.resolve().as_uri() - return ( - f'{base_uri}?type={geom_type}' - f'&crs={crs_str}' - f'&wktField=Geometry' - f'&delimiter=,' - f'&xField=X&yField=Y' - f'&spatialIndex=yes' - ) - - class TEMLoaderPlugin: def __init__(self, iface): self.iface = iface @@ -143,13 +131,7 @@ class TEMLoaderPlugin: '\n'.join(failed), ) - def _load_xyz(self, filepath): - points, doi_points, layers = core.process_xyz(filepath) - pts_csv = core.write_csv(points, filepath, '.points.csv') - doi_csv = core.write_csv(doi_points, filepath, '.doi.csv') if doi_points else None - lyr_csv = core.write_csv(layers, filepath, '.layers.csv') - - project = QgsProject.instance() + def _resolve_crs(self, filepath, project): crs = None source_epsg = core.detect_source_epsg(filepath) if source_epsg: @@ -162,18 +144,49 @@ class TEMLoaderPlugin: if not crs.isValid(): crs = QgsCoordinateReferenceSystem() crs.createFromString('EPSG:4326') - crs_str = crs.authid() + return crs - loaded_layers = {} - source_layers = [ - ('layers', lyr_csv, 'LineString'), + def _load_xyz(self, filepath): + points, doi_points, layers = core.process_xyz(filepath) + + project = QgsProject.instance() + crs = self._resolve_crs(filepath, project) + gpkg_path = filepath.with_suffix('.gpkg') + transform_context = project.transformContext() + + output_layers = [ + ('layers', layers, Qgis.WkbType.LineStringZ), ] - if doi_csv is not None: - source_layers.append(('doi', doi_csv, 'Point')) - source_layers.append(('points', pts_csv, 'Point')) - for name, csv_path, geom_type in source_layers: - uri = _build_delimited_text_uri(csv_path, geom_type, crs_str) - layer = QgsVectorLayer(uri, name, 'delimitedtext') + if doi_points: + output_layers.append(('doi', doi_points, Qgis.WkbType.PointZ)) + output_layers.append(('points', points, Qgis.WkbType.PointZ)) + + written_layer_names = [] + first_layer = True + for name, rows, wkb_type in output_layers: + if not rows: + continue + action = ( + QgsVectorFileWriter.CreateOrOverwriteFile + if first_layer + else QgsVectorFileWriter.CreateOrOverwriteLayer + ) + _write_geopackage_layer( + rows, + gpkg_path, + name, + wkb_type, + crs, + transform_context, + action, + ) + written_layer_names.append(name) + first_layer = False + + loaded_layers = {} + for name in written_layer_names: + uri = _build_geopackage_uri(gpkg_path, name) + layer = QgsVectorLayer(uri, name, 'ogr') if not layer.isValid(): continue @@ -198,7 +211,7 @@ class TEMLoaderPlugin: group.insertLayer(insert_index, layer) insert_index += 1 - failed = [name for name, _, _ in source_layers if name not in loaded_layers] + failed = [name for name in written_layer_names if name not in loaded_layers] if failed: QMessageBox.warning( self.iface.mainWindow(), diff --git a/test/test_core.py b/test/test_core.py @@ -444,13 +444,89 @@ class PluginTests(unittest.TestCase): self.features.extend(features) return self._add_features_result + class FakeCoordinateReferenceSystem: + def __init__(self, authid="EPSG:32632", valid=True): + self._authid = authid + self._valid = valid + + def createFromString(self, authid): + self._authid = authid + self._valid = True + return True + + def isValid(self): + return self._valid + + def authid(self): + return self._authid + + class FakeLayerGroup: + def __init__(self, name): + self.name = name + self.layers = [] + + def insertLayer(self, index, layer): + self.layers.insert(index, layer) + + class FakeLayerTreeRoot: + def __init__(self): + self.groups = [] + + def insertGroup(self, index, name): + group = FakeLayerGroup(name) + self.groups.insert(index, group) + return group + + class FakeProject: + _instance = None + + def __init__(self): + self._crs = FakeCoordinateReferenceSystem() + self._transform_context = object() + self.root = FakeLayerTreeRoot() + self.layers = [] + + @classmethod + def instance(cls): + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def crs(self): + return self._crs + + def transformContext(self): + return self._transform_context + + def addMapLayer(self, layer, add_to_legend=True): + self.layers.append((layer, add_to_legend)) + + def layerTreeRoot(self): + return self.root + + class FakeVectorLayer: + created = [] + valid_by_name = {} + + def __init__(self, uri, name, provider): + self.uri = uri + self.name = name + self.provider = provider + self.styles = [] + self._valid = self.valid_by_name.get(name, True) + FakeVectorLayer.created.append(self) + + def isValid(self): + return self._valid + + def loadNamedStyle(self, style_path): + self.styles.append(style_path) + qgis_core = types.ModuleType("qgis.core") qgis_core.Qgis = FakeQgis - qgis_core.QgsProject = type("QgsProject", (), {}) - qgis_core.QgsVectorLayer = type("QgsVectorLayer", (), {}) - qgis_core.QgsCoordinateReferenceSystem = type( - "QgsCoordinateReferenceSystem", (), {} - ) + qgis_core.QgsProject = FakeProject + qgis_core.QgsVectorLayer = FakeVectorLayer + qgis_core.QgsCoordinateReferenceSystem = FakeCoordinateReferenceSystem qgis_core.QgsFeature = FakeFeature qgis_core.QgsField = FakeField qgis_core.QgsFields = FakeFields @@ -613,3 +689,137 @@ class PluginTests(unittest.TestCase): object(), module.QgsVectorFileWriter.CreateOrOverwriteFile, ) + + def test_load_xyz_writes_geopackage_layers(self): + module, _, _ = self._import_plugin_module() + points = [ + { + "X": 1.0, + "Y": 2.0, + "Z": 3.0, + "Line": "1", + "StationNo": "1_00001", + "NumLayers": 1, + "Geometry": "POINT Z (1 2 3)", + } + ] + doi_points = [ + { + "X": 1.0, + "Y": 2.0, + "Z": -4.0, + "DOI": 7.0, + "ZDOI": -4.0, + "Geometry": "POINT Z (1 2 -4)", + } + ] + layers = [ + { + "X": 1.0, + "Y": 2.0, + "Z": 3.0, + "ZTop": 3.0, + "ZMid": 2.5, + "ZBottom": 2.0, + "DepthTop": 0.0, + "DepthBottom": 1.0, + "Resistivity": 10.0, + "Color": "#008cff", + "Layer": 1, + "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", + } + ] + module.core.process_xyz = Mock(return_value=(points, doi_points, layers)) + module.core.detect_source_epsg = Mock(return_value=None) + module.core.write_csv = Mock() + plugin = module.TEMLoaderPlugin(Mock()) + + plugin._load_xyz(Path("/tmp/model.xyz")) + + self.assertFalse(module.core.write_csv.called) + self.assertEqual( + [call["layerName"] for call in module.QgsVectorFileWriter.calls], + ["layers", "doi", "points"], + ) + self.assertEqual( + [call["geometryType"] for call in module.QgsVectorFileWriter.calls], + [ + module.Qgis.WkbType.LineStringZ, + module.Qgis.WkbType.PointZ, + module.Qgis.WkbType.PointZ, + ], + ) + self.assertEqual( + [ + call["actionOnExistingFile"] + for call in module.QgsVectorFileWriter.calls + ], + [ + module.QgsVectorFileWriter.CreateOrOverwriteFile, + module.QgsVectorFileWriter.CreateOrOverwriteLayer, + module.QgsVectorFileWriter.CreateOrOverwriteLayer, + ], + ) + gpkg_uri = str(Path("/tmp/model.gpkg").resolve()) + self.assertEqual( + [ + (layer.uri, layer.name, layer.provider) + for layer in module.QgsVectorLayer.created + ], + [ + (f"{gpkg_uri}|layername=layers", "layers", "ogr"), + (f"{gpkg_uri}|layername=doi", "doi", "ogr"), + (f"{gpkg_uri}|layername=points", "points", "ogr"), + ], + ) + project = module.QgsProject.instance() + self.assertEqual( + [(layer.name, add_to_legend) for layer, add_to_legend in project.layers], + [("layers", False), ("doi", False), ("points", False)], + ) + self.assertEqual(project.root.groups[0].name, "model") + self.assertEqual( + [layer.name for layer in project.root.groups[0].layers], + ["points", "doi", "layers"], + ) + + def test_load_xyz_skips_empty_doi_geopackage_layer(self): + module, _, _ = self._import_plugin_module() + points = [ + { + "X": 1.0, + "Y": 2.0, + "Z": 3.0, + "Geometry": "POINT Z (1 2 3)", + } + ] + layers = [ + { + "X": 1.0, + "Y": 2.0, + "Z": 3.0, + "Layer": 1, + "Geometry": "LINESTRING Z (1 2 3, 1 2 2)", + } + ] + module.core.process_xyz = Mock(return_value=(points, [], layers)) + module.core.detect_source_epsg = Mock(return_value=None) + module.core.write_csv = Mock() + plugin = module.TEMLoaderPlugin(Mock()) + + plugin._load_xyz(Path("/tmp/sci.xyz")) + + self.assertFalse(module.core.write_csv.called) + self.assertEqual( + [call["layerName"] for call in module.QgsVectorFileWriter.calls], + ["layers", "points"], + ) + self.assertEqual( + [layer.name for layer in module.QgsVectorLayer.created], + ["layers", "points"], + ) + project = module.QgsProject.instance() + self.assertEqual( + [layer.name for layer in project.root.groups[0].layers], + ["points", "layers"], + )