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 2d419de8628253b1febf87fb8648ec184bcd8701
parent 7736f5427c5fbbaec79dff32255f93873defa509
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date:   Mon, 27 Apr 2026 10:22:00 +0200

feat(layers): pre-compute resistivity Color field and wire 3D ambient color

Diffstat:
Mtem_loader/core.py | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mtem_loader/styles/layers.qml | 40++++++++++++++++++++++++++++++++++++++--
Mtest/test_core.py | 38+++++++++++++++++++++++++++++++++++++-
3 files changed, 122 insertions(+), 3 deletions(-)

diff --git a/tem_loader/core.py b/tem_loader/core.py @@ -34,6 +34,51 @@ HEADER_ALIASES = { 'UTMY': 'Y', } +# Resistivity ranges mirror the graduated symbology in styles/layers.qml. +# Each entry is (upper_exclusive, '#RRGGBB'); the final entry is the catch-all. +RESISTIVITY_COLOR_RAMP = ( + (0.1, '#000091'), + (1.0, '#000091'), + (2.0, '#0000b4'), + (4.0, '#0032dc'), + (7.0, '#005af5'), + (10.0, '#008cff'), + (15.0, '#00beff'), + (20.0, '#00dcff'), + (25.0, '#00ffff'), + (30.0, '#00ff96'), + (35.0, '#00ff00'), + (40.0, '#96ff00'), + (50.0, '#d2ff00'), + (60.0, '#ffff00'), + (75.0, '#ffb500'), + (90.0, '#ff7300'), + (120.0, '#ff0000'), + (150.0, '#ff1c8d'), + (200.0, '#ff6aff'), + (250.0, '#f200f2'), + (300.0, '#ca00ca'), + (400.0, '#a600a6'), + (600.0, '#800080'), + (1600.0, '#750075'), + (float('inf'), '#ffffff'), +) + + +def resistivity_color(value): + if value is None: + return RESISTIVITY_COLOR_RAMP[-1][1] + try: + v = float(value) + except (ValueError, TypeError): + return RESISTIVITY_COLOR_RAMP[-1][1] + if math.isnan(v): + return RESISTIVITY_COLOR_RAMP[-1][1] + for upper, color in RESISTIVITY_COLOR_RAMP: + if v < upper: + return color + return RESISTIVITY_COLOR_RAMP[-1][1] + def normalize_header_tokens(line): normalized = [] @@ -245,6 +290,7 @@ def process_xyz(path): 'DepthTop': depth_top, 'DepthBottom': depth_bottom, 'Resistivity': res, + 'Color': resistivity_color(res), 'Layer': i + 1, 'Geometry': layer_wkt, }) @@ -323,6 +369,7 @@ def _process_sci_rows(f, headers, header_line_number): 'DepthTop': depth_top, 'DepthBottom': depth_bottom, 'Resistivity': res, + 'Color': resistivity_color(res), 'Layer': layer_no, 'Geometry': layer_wkt, }) diff --git a/tem_loader/styles/layers.qml b/tem_loader/styles/layers.qml @@ -1,5 +1,26 @@ <!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'> -<qgis autoRefreshMode="Disabled" autoRefreshTime="0" hasScaleBasedVisibilityFlag="0" labelsEnabled="0" layerType="Vector" maxScale="0" minScale="100000000" readOnly="0" simplifyAlgorithm="0" simplifyDrawingHints="1" simplifyDrawingTol="1" simplifyLocal="1" simplifyMaxScale="1" styleCategories="AllStyleCategories" symbologyReferenceScale="-1" version="4.0.0-Norrköping"> +<qgis autoRefreshMode="Disabled" autoRefreshTime="0" hasScaleBasedVisibilityFlag="0" labelsEnabled="0" layerType="Vector" maxScale="0" minScale="100000000" readOnly="0" simplifyAlgorithm="0" simplifyDrawingHints="1" simplifyDrawingTol="1" simplifyLocal="1" simplifyMaxScale="1" styleCategories="AllStyleCategories" symbologyReferenceScale="-1" version="4.0.1-Norrköping"> + <renderer-3d layer="layers_219f0a67_bfa6_4c9e_97ae_f671f176d748" type="vector"> + <vector-layer-3d-tiling max-chunk-features="1000" show-bounding-boxes="0" zoom-levels-count="3"/> + <symbol material_type="simpleline" type="line"> + <data alt-binding="centroid" alt-clamping="absolute" extrusion-height="0" offset="0" simple-lines="1" width="6"/> + <material ambient="26,26,26,255,rgb:0.1000076,0.1000076,0.1000076,1"> + <data-defined-properties> + <Option type="Map"> + <Option name="name" type="QString" value=""/> + <Option name="properties" type="Map"> + <Option name="ambient" type="Map"> + <Option name="active" type="bool" value="true"/> + <Option name="expression" type="QString" value="&quot;Color&quot;"/> + <Option name="type" type="int" value="3"/> + </Option> + </Option> + <Option name="type" type="QString" value="collection"/> + </Option> + </data-defined-properties> + </material> + </symbol> + </renderer-3d> <flags> <Identifiable>1</Identifiable> <Removable>1</Removable> @@ -1560,6 +1581,13 @@ </config> </editWidget> </field> + <field configurationFlags="NoFlag" name="Color"> + <editWidget type="TextEdit"> + <config> + <Option/> + </config> + </editWidget> + </field> <field configurationFlags="NoFlag" name="Layer"> <editWidget type="Range"> <config> @@ -1578,7 +1606,8 @@ <alias field="DepthTop" index="6" name=""/> <alias field="DepthBottom" index="7" name=""/> <alias field="Resistivity" index="8" name=""/> - <alias field="Layer" index="9" name=""/> + <alias field="Color" index="9" name=""/> + <alias field="Layer" index="10" name=""/> </aliases> <defaults> <default applyOnUpdate="0" expression="" field="X"/> @@ -1590,6 +1619,7 @@ <default applyOnUpdate="0" expression="" field="DepthTop"/> <default applyOnUpdate="0" expression="" field="DepthBottom"/> <default applyOnUpdate="0" expression="" field="Resistivity"/> + <default applyOnUpdate="0" expression="" field="Color"/> <default applyOnUpdate="0" expression="" field="Layer"/> </defaults> <constraints> @@ -1602,6 +1632,7 @@ <constraint constraints="0" exp_strength="0" field="DepthTop" notnull_strength="0" unique_strength="0"/> <constraint constraints="0" exp_strength="0" field="DepthBottom" notnull_strength="0" unique_strength="0"/> <constraint constraints="0" exp_strength="0" field="Resistivity" notnull_strength="0" unique_strength="0"/> + <constraint constraints="0" exp_strength="0" field="Color" notnull_strength="0" unique_strength="0"/> <constraint constraints="0" exp_strength="0" field="Layer" notnull_strength="0" unique_strength="0"/> </constraints> <constraintExpressions> @@ -1614,6 +1645,7 @@ <constraint desc="" exp="" field="DepthTop"/> <constraint desc="" exp="" field="DepthBottom"/> <constraint desc="" exp="" field="Resistivity"/> + <constraint desc="" exp="" field="Color"/> <constraint desc="" exp="" field="Layer"/> </constraintExpressions> <expressionfields/> @@ -1632,6 +1664,7 @@ <column hidden="0" name="DepthBottom" type="field" width="-1"/> <column hidden="0" name="Resistivity" type="field" width="-1"/> <column hidden="0" name="Layer" type="field" width="-1"/> + <column hidden="0" name="Color" type="field" width="-1"/> <column hidden="1" type="actions" width="-1"/> </columns> </attributetableconfig> @@ -1664,6 +1697,7 @@ def my_form_open(dialog, layer, feature): <featformsuppress>0</featformsuppress> <editorlayout>generatedlayout</editorlayout> <editable> + <field editable="1" name="Color"/> <field editable="1" name="DepthBottom"/> <field editable="1" name="DepthTop"/> <field editable="1" name="Layer"/> @@ -1676,6 +1710,7 @@ def my_form_open(dialog, layer, feature): <field editable="1" name="ZTop"/> </editable> <labelOnTop> + <field labelOnTop="0" name="Color"/> <field labelOnTop="0" name="DepthBottom"/> <field labelOnTop="0" name="DepthTop"/> <field labelOnTop="0" name="Layer"/> @@ -1688,6 +1723,7 @@ def my_form_open(dialog, layer, feature): <field labelOnTop="0" name="ZTop"/> </labelOnTop> <reuseLastValuePolicy> + <field name="Color" reuseLastValuePolicy="NotAllowed"/> <field name="DepthBottom" reuseLastValuePolicy="NotAllowed"/> <field name="DepthTop" reuseLastValuePolicy="NotAllowed"/> <field name="Layer" reuseLastValuePolicy="NotAllowed"/> diff --git a/test/test_core.py b/test/test_core.py @@ -8,7 +8,12 @@ import unittest from unittest.mock import Mock, patch import xml.etree.ElementTree as ET -from tem_loader.core import detect_source_epsg, process_xyz, write_csv +from tem_loader.core import ( + detect_source_epsg, + process_xyz, + resistivity_color, + write_csv, +) FIXTURE_DIR = Path(__file__).parent / "data" @@ -125,6 +130,37 @@ class ProcessXYZTests(unittest.TestCase): self.assertEqual(layers[0]["DepthBottom"], 1.0) self.assertAlmostEqual(layers[0]["Resistivity"], 31.1, places=6) + def test_resistivity_color_buckets(self): + self.assertEqual(resistivity_color(-5), "#000091") + self.assertEqual(resistivity_color(0.5), "#000091") + self.assertEqual(resistivity_color(31.1), "#00ff00") + self.assertEqual(resistivity_color(60), "#ffb500") + self.assertEqual(resistivity_color(90), "#ff0000") + self.assertEqual(resistivity_color(125), "#ff1c8d") + self.assertEqual(resistivity_color(2000), "#ffffff") + self.assertEqual(resistivity_color(float("nan")), "#ffffff") + self.assertEqual(resistivity_color(None), "#ffffff") + + def test_layer_rows_include_color_field(self): + path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" + _, _, layers = process_xyz(path) + self.assertEqual(layers[0]["Color"], resistivity_color(layers[0]["Resistivity"])) + self.assertTrue(all("Color" in row for row in layers)) + + def test_layers_3d_renderer_uses_color_field(self): + tree = ET.parse(STYLES_DIR / "layers.qml") + renderer = tree.getroot().find("./renderer-3d") + self.assertIsNotNone(renderer) + ambient = renderer.find( + ".//material/data-defined-properties/Option/Option[@name='properties']" + "/Option[@name='ambient']" + ) + self.assertIsNotNone(ambient) + active = ambient.find("./Option[@name='active']") + expr = ambient.find("./Option[@name='expression']") + self.assertEqual(active.attrib["value"], "true") + self.assertEqual(expr.attrib["value"], '"Color"') + def test_detect_source_epsg_for_sci_fixture(self): path = FIXTURE_DIR / "sci_workbench_2026_1.xyz" self.assertEqual(detect_source_epsg(path), "EPSG:25832")