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:
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)}',
+ )