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:
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=""Color""/>
+ <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")