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 eea620f476dc695a1f9180003d6611110ea2c5ab
parent 62608b901a7b342cf11b2258fc67d423b4f0f778
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Wed,  8 Apr 2026 15:43:32 +0200

feat: add QGIS plugin wrapping xyz conversion logic

Introduces the tem_loader QGIS plugin:
- core.py: pandas-free rewrite of unpivot-xyz.py (stdlib csv + pathlib)
- tem_loader.py: plugin class with menu action, file dialog, and
  styled layer loading into a named layer tree group
- metadata.txt, __init__.py: QGIS plugin registry boilerplate
- styles/ moved into the plugin package for bundled distribution
- Makefile: package/clean targets for zip-based install

Diffstat:
AMakefile | 10++++++++++
Atem_loader/__init__.py | 3+++
Atem_loader/core.py | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atem_loader/metadata.txt | 8++++++++
Rstyles/doi.qml -> tem_loader/styles/doi.qml | 0
Rstyles/layers.qml -> tem_loader/styles/layers.qml | 0
Rstyles/points.qml -> tem_loader/styles/points.qml | 0
Atem_loader/tem_loader.py | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 238 insertions(+), 0 deletions(-)

diff --git a/Makefile b/Makefile @@ -0,0 +1,10 @@ +PLUGIN_NAME = tem_loader +ZIPFILE = $(PLUGIN_NAME).zip + +.PHONY: package clean + +package: + zip -r $(ZIPFILE) $(PLUGIN_NAME)/ + +clean: + rm -f $(ZIPFILE) diff --git a/tem_loader/__init__.py b/tem_loader/__init__.py @@ -0,0 +1,3 @@ +def classFactory(iface): + from .tem_loader import TEMLoaderPlugin + return TEMLoaderPlugin(iface) diff --git a/tem_loader/core.py b/tem_loader/core.py @@ -0,0 +1,128 @@ +import csv +import math +from pathlib import Path + + +def count_header_lines(path, comment_char='/'): + with open(path, 'r') as f: + for i, line in enumerate(f): + if not line.lstrip().startswith(comment_char): + return i + return 0 + + +def process_xyz(path): + skiprows = count_header_lines(path) + with open(path, 'r') as f: + lines = f.readlines() + + data_lines = lines[skiprows:] + headers = data_lines[0].split() + data_rows = [] + for line in data_lines[1:]: + line = line.strip() + if not line: + continue + values = line.split() + # When the data has one more column than the header, pandas would + # treat the first column as the row index. Replicate that here. + if len(values) == len(headers) + 1: + values = values[1:] + data_rows.append(dict(zip(headers, values))) + + res_cols = [c for c in headers if c.startswith('Res_')] + thick_cols = [c for c in headers if c.startswith('Thick_')] + + points = [] + doi_points = [] + layers = [] + + for row in data_rows: + x = float(row['X']) + y = float(row['Y']) + z = float(row['Z']) + doi = float(row['DOI']) + data_residual = float(row['DataResidual']) + + raw_n = row['NumLayers'] + try: + n_layers = int(float(raw_n)) + if math.isnan(float(raw_n)): + n_layers = None + except (ValueError, TypeError): + n_layers = None + + z_doi = z - doi + point_wkt = f'POINT Z ({x} {y} {z})' + doi_wkt = f'POINT Z ({x} {y} {z_doi})' + + points.append({ + 'X': x, + 'Y': y, + 'Z': z, + 'Line': row['Line'], + 'StationNo': row['StationNo'], + 'DataResidual': data_residual, + 'NumLayers': n_layers, + 'Geometry': point_wkt, + }) + doi_points.append({ + 'X': x, + 'Y': y, + 'Z': z_doi, + 'DOI': doi, + 'ZDOI': z_doi, + 'Geometry': doi_wkt, + }) + + layer_cols = list(zip(res_cols, thick_cols)) + if n_layers is not None: + layer_cols = layer_cols[:n_layers] + + cum_depth = 0.0 + for i, (res_col, thick_col) in enumerate(layer_cols, 1): + res_val = row.get(res_col, '') + thick_val = row.get(thick_col, '') + try: + res = float(res_val) + thick = float(thick_val) + except (ValueError, TypeError): + break + if math.isnan(res) or math.isnan(thick): + break + + depth_top = cum_depth + depth_bottom = cum_depth + thick + 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({ + 'X': x, + 'Y': y, + 'Z': z, + 'ZTop': z_top, + 'ZMid': z_mid, + 'ZBottom': z_bot, + 'DepthTop': depth_top, + 'DepthBottom': depth_bottom, + 'Resistivity': res, + 'Layer': i, + 'Geometry': layer_wkt, + }) + + return points, doi_points, layers + + +def write_csv(rows, base_path, suffix): + out_path = Path(base_path).with_suffix(suffix) + if not rows: + out_path.write_text('') + return out_path + with open(out_path, 'w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + return out_path diff --git a/tem_loader/metadata.txt b/tem_loader/metadata.txt @@ -0,0 +1,8 @@ +[general] +name=TEM Loader +qgisMinimumVersion=3.4 +qgisMaximumVersion=4.1 +description=Load TEM inversion XYZ files as styled 3D vector layers +version=0.1 +author=Anders Damsgaard +email=andam@geus.dk diff --git a/styles/doi.qml b/tem_loader/styles/doi.qml diff --git a/styles/layers.qml b/tem_loader/styles/layers.qml diff --git a/styles/points.qml b/tem_loader/styles/points.qml diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py @@ -0,0 +1,89 @@ +from pathlib import Path + +from qgis.PyQt.QtWidgets import QAction, QFileDialog, QMessageBox +from qgis.core import ( + QgsProject, + QgsVectorLayer, + QgsCoordinateReferenceSystem, + QgsLayerTreeGroup, +) + +from . import core + + +STYLES_DIR = Path(__file__).parent / 'styles' + + +class TEMLoaderPlugin: + def __init__(self, iface): + self.iface = iface + self._action = None + + def initGui(self): + self._action = QAction('Load TEM XYZ files\u2026', self.iface.mainWindow()) + self._action.triggered.connect(self.run) + self.iface.addPluginToMenu('TEM Loader', self._action) + + def unload(self): + self.iface.removePluginMenu('TEM Loader', self._action) + self._action = None + + def run(self): + paths, _ = QFileDialog.getOpenFileNames( + self.iface.mainWindow(), + 'Select TEM XYZ files', + '', + 'XYZ files (*.xyz);;All files (*)', + ) + for path in paths: + self._load_xyz(Path(path)) + + 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') + lyr_csv = core.write_csv(layers, filepath, '.layers.csv') + + project = QgsProject.instance() + crs = project.crs() + if not crs.isValid(): + crs = QgsCoordinateReferenceSystem('EPSG:4326') + crs_str = crs.authid() + + group_name = filepath.stem + root = project.layerTreeRoot() + group = root.insertGroup(0, group_name) + + failed = [] + load_order = [ + ('layers', lyr_csv, 'LineString'), + ('doi', doi_csv, 'Point'), + ('points', pts_csv, 'Point'), + ] + for name, csv_path, geom_type in load_order: + uri = ( + f'file://{csv_path.as_posix()}' + f'?type={geom_type}' + f'&crs={crs_str}' + f'&wktField=Geometry' + f'&delimiter=,' + f'&xField=X&yField=Y' + ) + layer = QgsVectorLayer(uri, name, 'delimitedtext') + if not layer.isValid(): + failed.append(name) + continue + + qml = STYLES_DIR / f'{name}.qml' + if qml.exists(): + layer.loadNamedStyle(str(qml)) + + project.addMapLayer(layer, False) + group.addLayer(layer) + + if failed: + QMessageBox.warning( + self.iface.mainWindow(), + 'TEM Loader', + f'Failed to load layers: {", ".join(failed)}', + )