commit 3c79885b68e52a986c065fc614fe6f482c2c01c1
parent 9193f1d07b7567a5350ab944be9b4196df4768be
Author: Anders Damsgaard <anders@adamsgaard.dk>
Date: Fri, 15 May 2026 22:07:39 +0200
feat: merge layer opacity feature
Diffstat:
5 files changed, 369 insertions(+), 43 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -0,0 +1,56 @@
+# TEM Loader Plugin
+
+QGIS 3.40+ plugin for loading Time-Domain Electromagnetic (TEM) inversion XYZ files as styled 3D vector layers. Python, no external dependencies beyond QGIS itself.
+
+## Commands
+
+```sh
+make test # run the unittest suite
+python3 -m unittest discover -s test -p 'test_*.py' # same test runner without Make
+python3 -m unittest test.test_core.ProcessXYZTests.test_temimage_4_0_4_6_fixture # single test
+make package # build tem_loader.zip for installation
+make clean # remove the zip
+```
+
+CI runs `make test` on Python 3.12; the GitLab pipeline also produces `tem_loader.zip` as an artifact.
+
+## Boundaries
+
+**Allowed without asking:**
+- Read any file, list directories, run single-file type checks and tests
+- Create or edit files in `tem_loader/`, `test/`, and `tem_loader/styles/`
+
+**Ask first:**
+- Installing new Python dependencies (project has none)
+- Deleting test fixtures from `test/data/`
+- Pushing to git (`git-push.sh` pushes to origin, own, and geus remotes)
+
+**Never touch:**
+- `.env`, secrets, or credentials
+- Binary video files (`*.mov`, `*.mp4`) — they are demos, not code
+- Large XYZ fixture data in the project root (development samples only)
+
+## Architecture
+
+- `tem_loader/core.py` — XYZ parsing, CSV writing, resistivity color mapping. No QGIS dependency; fully unit-testable.
+- `tem_loader/tem_loader.py` — QGIS plugin glue: dialog, layer creation, CRS resolution, styling, group organization.
+- `tem_loader/styles/*.qml` — QGIS layer style files for points, DOI, and layers (supports 3D renderer with dynamic color).
+- `test/data/` — fixture XYZ files for the three supported export families: TEMImage, Aarhus Workbench, and SCI Workbench.
+
+## Conventions
+
+- Fallible operations raise `ValueError` with descriptive messages; callers catch and present them to the user (not swallowed).
+- Multiple file processing continues after errors per-file, collecting failures into a single warning dialog.
+- CRS resolution: source EPSG from file metadata > project CRS > EPSG:4326 fallback.
+- Test fixtures live in `test/data/`; tests assert counts, specific field values, and QXML structure (for style verification).
+
+## Git
+
+- Commit messages follow conventional commits, for example `feat(parser): ...`, `fix(qgis): ...`, `build: ...`, and `chore(release): ...`.
+- Do not push without approval; `git-push.sh` pushes to `origin`, `own`, and `geus`.
+
+## Gotchas
+
+- `core.py` must remain free of QGIS imports — it is the only module tested without a running QGIS instance.
+- The SCI Workbench format aggregates per-layer rows into soundings at parse time; its code path diverges from the other two formats.
+- XYZ files with an extra leading index column (pandas-style) must be handled by dropping the first value before zipping.
diff --git a/tem_loader/core.py b/tem_loader/core.py
@@ -45,6 +45,15 @@ HEADER_ALIASES = {
'UTMY': 'Y',
}
+ABOVE_DOI_OPACITY = 100
+BELOW_DOI_OPACITY = 10
+
+
+def layer_opacity(depth_mid, doi):
+ if doi is None:
+ return ABOVE_DOI_OPACITY
+ return ABOVE_DOI_OPACITY if depth_mid <= doi else BELOW_DOI_OPACITY
+
# 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 = (
@@ -297,6 +306,7 @@ def process_xyz(path):
depth_top = cum_depth
depth_bottom = cum_depth + thick
+ depth_mid = (depth_top + depth_bottom) / 2
z_top = z - depth_top
z_bot = z - depth_bottom
z_mid = (z_top + z_bot) / 2
@@ -314,6 +324,7 @@ def process_xyz(path):
'DepthBottom': depth_bottom,
'Resistivity': res,
'Color': resistivity_color(res),
+ 'Opacity': layer_opacity(depth_mid, doi),
'Layer': i + 1,
'Geometry': layer_wkt,
})
@@ -356,6 +367,7 @@ def _append_atem_sci_row(row, res_cols, elev_cols, points, doi_points, layers):
z_mid = (z_top + z_bot) / 2
depth_top = z - z_top
depth_bottom = z - z_bot
+ depth_mid = (depth_top + depth_bottom) / 2
layer_rows.append({
'X': x,
'Y': y,
@@ -367,6 +379,7 @@ def _append_atem_sci_row(row, res_cols, elev_cols, points, doi_points, layers):
'DepthBottom': depth_bottom,
'Resistivity': res,
'Color': resistivity_color(res),
+ 'Opacity': layer_opacity(depth_mid, doi),
'Layer': i + 1,
'Geometry': f'LINESTRING Z ({x} {y} {z_top}, {x} {y} {z_bot})',
})
@@ -461,6 +474,7 @@ def _process_sci_rows(f, headers, header_line_number):
'DepthBottom': depth_bottom,
'Resistivity': res,
'Color': resistivity_color(res),
+ 'Opacity': layer_opacity(depth_bottom, None),
'Layer': layer_no,
'Geometry': layer_wkt,
})
diff --git a/tem_loader/styles/layers.qml b/tem_loader/styles/layers.qml
@@ -1,6 +1,6 @@
<!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.1-Norrköping">
- <renderer-3d layer="layers_219f0a67_bfa6_4c9e_97ae_f671f176d748" type="vector">
+<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.2-Norrköping">
+ <renderer-3d layer="layers_12b54bb4_518f_47ff_a696_ce88ee7fec58" 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"/>
@@ -27,7 +27,7 @@
<Searchable>1</Searchable>
<Private>0</Private>
</flags>
- <temporal accumulate="0" durationField="X" durationUnit="min" enabled="0" endExpression="" endField="" fixedDuration="0" limitMode="0" mode="0" startExpression="" startField="">
+ <temporal accumulate="0" durationField="fid" durationUnit="min" enabled="0" endExpression="" endField="" fixedDuration="0" limitMode="0" mode="0" startExpression="" startField="">
<fixedRange>
<start></start>
<end></end>
@@ -198,7 +198,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -245,7 +251,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -292,7 +304,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -339,7 +357,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -386,7 +410,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -433,7 +463,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -480,7 +516,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -527,7 +569,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -574,7 +622,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -621,7 +675,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -668,7 +728,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -715,7 +781,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -762,7 +834,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -809,7 +887,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -856,7 +940,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -903,7 +993,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -950,7 +1046,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -997,7 +1099,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1044,7 +1152,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1091,7 +1205,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1138,7 +1258,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1185,7 +1311,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1232,7 +1364,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1279,7 +1417,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1326,7 +1470,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1375,7 +1525,13 @@
<data_defined_properties>
<Option type="Map">
<Option name="name" type="QString" value=""/>
- <Option name="properties"/>
+ <Option name="properties" type="Map">
+ <Option name="alpha" type="Map">
+ <Option name="active" type="bool" value="true"/>
+ <Option name="field" type="QString" value="Opacity"/>
+ <Option name="type" type="int" value="2"/>
+ </Option>
+ </Option>
<Option name="type" type="QString" value="collection"/>
</Option>
</data_defined_properties>
@@ -1518,6 +1674,13 @@
<referencedLayers/>
<referencingLayers/>
<fieldConfiguration>
+ <field configurationFlags="NoFlag" name="fid">
+ <editWidget type="TextEdit">
+ <config>
+ <Option/>
+ </config>
+ </editWidget>
+ </field>
<field configurationFlags="NoFlag" name="X">
<editWidget type="TextEdit">
<config>
@@ -1588,6 +1751,13 @@
</config>
</editWidget>
</field>
+ <field configurationFlags="NoFlag" name="Opacity">
+ <editWidget type="Range">
+ <config>
+ <Option/>
+ </config>
+ </editWidget>
+ </field>
<field configurationFlags="NoFlag" name="Layer">
<editWidget type="Range">
<config>
@@ -1597,19 +1767,22 @@
</field>
</fieldConfiguration>
<aliases>
- <alias field="X" index="0" name=""/>
- <alias field="Y" index="1" name=""/>
- <alias field="Z" index="2" name=""/>
- <alias field="ZTop" index="3" name=""/>
- <alias field="ZMid" index="4" name=""/>
- <alias field="ZBottom" index="5" name=""/>
- <alias field="DepthTop" index="6" name=""/>
- <alias field="DepthBottom" index="7" name=""/>
- <alias field="Resistivity" index="8" name=""/>
- <alias field="Color" index="9" name=""/>
- <alias field="Layer" index="10" name=""/>
+ <alias field="fid" index="0" name=""/>
+ <alias field="X" index="1" name=""/>
+ <alias field="Y" index="2" name=""/>
+ <alias field="Z" index="3" name=""/>
+ <alias field="ZTop" index="4" name=""/>
+ <alias field="ZMid" index="5" name=""/>
+ <alias field="ZBottom" index="6" name=""/>
+ <alias field="DepthTop" index="7" name=""/>
+ <alias field="DepthBottom" index="8" name=""/>
+ <alias field="Resistivity" index="9" name=""/>
+ <alias field="Color" index="10" name=""/>
+ <alias field="Opacity" index="11" name=""/>
+ <alias field="Layer" index="12" name=""/>
</aliases>
<defaults>
+ <default applyOnUpdate="0" expression="" field="fid"/>
<default applyOnUpdate="0" expression="" field="X"/>
<default applyOnUpdate="0" expression="" field="Y"/>
<default applyOnUpdate="0" expression="" field="Z"/>
@@ -1620,9 +1793,11 @@
<default applyOnUpdate="0" expression="" field="DepthBottom"/>
<default applyOnUpdate="0" expression="" field="Resistivity"/>
<default applyOnUpdate="0" expression="" field="Color"/>
+ <default applyOnUpdate="0" expression="" field="Opacity"/>
<default applyOnUpdate="0" expression="" field="Layer"/>
</defaults>
<constraints>
+ <constraint constraints="3" exp_strength="0" field="fid" notnull_strength="1" unique_strength="1"/>
<constraint constraints="0" exp_strength="0" field="X" notnull_strength="0" unique_strength="0"/>
<constraint constraints="0" exp_strength="0" field="Y" notnull_strength="0" unique_strength="0"/>
<constraint constraints="0" exp_strength="0" field="Z" notnull_strength="0" unique_strength="0"/>
@@ -1633,9 +1808,11 @@
<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="Opacity" notnull_strength="0" unique_strength="0"/>
<constraint constraints="0" exp_strength="0" field="Layer" notnull_strength="0" unique_strength="0"/>
</constraints>
<constraintExpressions>
+ <constraint desc="" exp="" field="fid"/>
<constraint desc="" exp="" field="X"/>
<constraint desc="" exp="" field="Y"/>
<constraint desc="" exp="" field="Z"/>
@@ -1646,6 +1823,7 @@
<constraint desc="" exp="" field="DepthBottom"/>
<constraint desc="" exp="" field="Resistivity"/>
<constraint desc="" exp="" field="Color"/>
+ <constraint desc="" exp="" field="Opacity"/>
<constraint desc="" exp="" field="Layer"/>
</constraintExpressions>
<expressionfields/>
@@ -1665,6 +1843,8 @@
<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="0" name="fid" type="field" width="-1"/>
+ <column hidden="0" name="Opacity" type="field" width="-1"/>
<column hidden="1" type="actions" width="-1"/>
</columns>
</attributetableconfig>
@@ -1701,6 +1881,7 @@ def my_form_open(dialog, layer, feature):
<field editable="1" name="DepthBottom"/>
<field editable="1" name="DepthTop"/>
<field editable="1" name="Layer"/>
+ <field editable="1" name="Opacity"/>
<field editable="1" name="Resistivity"/>
<field editable="1" name="X"/>
<field editable="1" name="Y"/>
@@ -1708,12 +1889,14 @@ def my_form_open(dialog, layer, feature):
<field editable="1" name="ZBottom"/>
<field editable="1" name="ZMid"/>
<field editable="1" name="ZTop"/>
+ <field editable="1" name="fid"/>
</editable>
<labelOnTop>
<field labelOnTop="0" name="Color"/>
<field labelOnTop="0" name="DepthBottom"/>
<field labelOnTop="0" name="DepthTop"/>
<field labelOnTop="0" name="Layer"/>
+ <field labelOnTop="0" name="Opacity"/>
<field labelOnTop="0" name="Resistivity"/>
<field labelOnTop="0" name="X"/>
<field labelOnTop="0" name="Y"/>
@@ -1721,12 +1904,14 @@ def my_form_open(dialog, layer, feature):
<field labelOnTop="0" name="ZBottom"/>
<field labelOnTop="0" name="ZMid"/>
<field labelOnTop="0" name="ZTop"/>
+ <field labelOnTop="0" name="fid"/>
</labelOnTop>
<reuseLastValuePolicy>
<field name="Color" reuseLastValuePolicy="NotAllowed"/>
<field name="DepthBottom" reuseLastValuePolicy="NotAllowed"/>
<field name="DepthTop" reuseLastValuePolicy="NotAllowed"/>
<field name="Layer" reuseLastValuePolicy="NotAllowed"/>
+ <field name="Opacity" reuseLastValuePolicy="NotAllowed"/>
<field name="Resistivity" reuseLastValuePolicy="NotAllowed"/>
<field name="X" reuseLastValuePolicy="NotAllowed"/>
<field name="Y" reuseLastValuePolicy="NotAllowed"/>
@@ -1734,6 +1919,7 @@ def my_form_open(dialog, layer, feature):
<field name="ZBottom" reuseLastValuePolicy="NotAllowed"/>
<field name="ZMid" reuseLastValuePolicy="NotAllowed"/>
<field name="ZTop" reuseLastValuePolicy="NotAllowed"/>
+ <field name="fid" reuseLastValuePolicy="NotAllowed"/>
</reuseLastValuePolicy>
<dataDefinedFieldProperties/>
<widgets/>
diff --git a/tem_loader/tem_loader.py b/tem_loader/tem_loader.py
@@ -20,7 +20,7 @@ from . import core
STYLES_DIR = Path(__file__).parent / 'styles'
GEOMETRY_FIELD = 'Geometry'
STRING_FIELDS = {'Line', 'StationNo', 'Color'}
-INTEGER_FIELDS = {'NumLayers', 'Layer'}
+INTEGER_FIELDS = {'NumLayers', 'Layer', 'Opacity'}
def _build_geopackage_uri(gpkg_path, layer_name):
diff --git a/test/test_core.py b/test/test_core.py
@@ -9,7 +9,10 @@ from unittest.mock import Mock, patch
import xml.etree.ElementTree as ET
from tem_loader.core import (
+ ABOVE_DOI_OPACITY,
+ BELOW_DOI_OPACITY,
detect_source_epsg,
+ layer_opacity,
process_xyz,
resistivity_color,
write_csv,
@@ -167,6 +170,69 @@ class ProcessXYZTests(unittest.TestCase):
self.assertAlmostEqual(layers[0]["Resistivity"], 0.2732)
self.assertEqual(layers[0]["Color"], resistivity_color(0.2732))
+ def test_opacity_constants(self):
+ self.assertEqual(ABOVE_DOI_OPACITY, 100)
+ self.assertEqual(BELOW_DOI_OPACITY, 10)
+
+ def test_layer_opacity_returns_above_when_doi_is_none(self):
+ self.assertEqual(layer_opacity(50.0, None), ABOVE_DOI_OPACITY)
+ self.assertEqual(layer_opacity(0.0, None), ABOVE_DOI_OPACITY)
+ self.assertEqual(layer_opacity(999.0, None), ABOVE_DOI_OPACITY)
+
+ def test_layer_opacity_returns_above_when_midpoint_depth_at_doi(self):
+ self.assertEqual(layer_opacity(10.0, 10.0), ABOVE_DOI_OPACITY)
+ self.assertEqual(layer_opacity(0.0, 50.0), ABOVE_DOI_OPACITY)
+
+ def test_layer_opacity_returns_below_when_midpoint_depth_exceeds_doi(self):
+ self.assertEqual(layer_opacity(10.1, 10.0), BELOW_DOI_OPACITY)
+ self.assertEqual(layer_opacity(200.0, 50.0), BELOW_DOI_OPACITY)
+
+ def test_crossing_layer_is_above_when_midpoint_is_at_doi(self):
+ with TemporaryDirectory() as tmp:
+ path = Path(tmp) / "midpoint.xyz"
+ path.write_text(
+ "/ Line StationNo X Y Z DOI DataResidual NumLayers "
+ "Res_001 Res_002 Res_003 Thick_001 Thick_002 Thick_003\n"
+ "1 1 0 0 100 15 0 3 10 20 30 10 10 10\n"
+ )
+
+ _, _, layers = process_xyz(path)
+
+ self.assertEqual(layers[0]["Opacity"], ABOVE_DOI_OPACITY)
+ self.assertEqual(layers[1]["DepthTop"], 10.0)
+ self.assertEqual(layers[1]["DepthBottom"], 20.0)
+ self.assertEqual(layers[1]["Opacity"], ABOVE_DOI_OPACITY)
+ self.assertEqual(layers[2]["Opacity"], BELOW_DOI_OPACITY)
+
+ def test_fixture_layers_have_correct_opacity(self):
+ # TEMImage fixture has DOI, so opacity depends on midpoint depth vs DOI.
+ # Each sounding has its own DOI; we verify the first sounding's layers.
+ path = FIXTURE_DIR / "profiler_temimager_4_0_4_6.xyz"
+ points, doi_points, layers = process_xyz(path)
+ self.assertTrue(all("Opacity" in row for row in layers))
+ # Every layer's opacity is one of the two valid values
+ self.assertTrue(
+ all(row["Opacity"] in (ABOVE_DOI_OPACITY, BELOW_DOI_OPACITY) for row in layers)
+ )
+ # The first sounding's DOI and layer count
+ first_doi = doi_points[0]["DOI"]
+ first_n = points[0]["NumLayers"]
+ first_layers = layers[:first_n]
+ for layer in first_layers:
+ depth_mid = (layer["DepthTop"] + layer["DepthBottom"]) / 2
+ expected = ABOVE_DOI_OPACITY if depth_mid <= first_doi else BELOW_DOI_OPACITY
+ self.assertEqual(
+ layer["Opacity"],
+ expected,
+ f"Layer {layer['Layer']} midpoint depth {depth_mid} vs DOI {first_doi}",
+ )
+
+ def test_sci_fixture_layers_all_above_opacity(self):
+ # SCI format has no DOI, so all layers get ABOVE_DOI_OPACITY
+ path = FIXTURE_DIR / "sci_workbench_2026_1.xyz"
+ _, _, layers = process_xyz(path)
+ self.assertTrue(all(row["Opacity"] == ABOVE_DOI_OPACITY for row in layers))
+
def test_resistivity_color_buckets(self):
self.assertEqual(resistivity_color(-5), "#000091")
self.assertEqual(resistivity_color(0.5), "#000091")
@@ -598,6 +664,7 @@ class PluginTests(unittest.TestCase):
"StationNo": "1_00001",
"NumLayers": 30,
"Layer": 1,
+ "Opacity": 100,
"Color": "#00ff00",
"Geometry": "POINT Z (1 2 3)",
}
@@ -607,7 +674,7 @@ class PluginTests(unittest.TestCase):
self.assertEqual(
[field.name() for field in fields],
- ["X", "Line", "StationNo", "NumLayers", "Layer", "Color"],
+ ["X", "Line", "StationNo", "NumLayers", "Layer", "Opacity", "Color"],
)
self.assertEqual(
[field.field_type for field in fields],
@@ -617,6 +684,7 @@ class PluginTests(unittest.TestCase):
module.QMetaType.Type.QString,
module.QMetaType.Type.Int,
module.QMetaType.Type.Int,
+ module.QMetaType.Type.Int,
module.QMetaType.Type.QString,
],
)
@@ -645,6 +713,7 @@ class PluginTests(unittest.TestCase):
"X": 1.0,
"Y": 2.0,
"Layer": 1,
+ "Opacity": 10,
"Color": "#00ff00",
"Geometry": "LINESTRING Z (1 2 3, 1 2 2)",
}
@@ -682,7 +751,7 @@ class PluginTests(unittest.TestCase):
)
self.assertEqual(
writer.features[0].attributes,
- [1.0, 2.0, 1, "#00ff00"],
+ [1.0, 2.0, 1, 10, "#00ff00"],
)
def test_write_geopackage_layer_reports_writer_error(self):
@@ -740,6 +809,7 @@ class PluginTests(unittest.TestCase):
"DepthTop": 0.0,
"DepthBottom": 1.0,
"Resistivity": 10.0,
+ "Opacity": 100,
"Color": "#008cff",
"Layer": 1,
"Geometry": "LINESTRING Z (1 2 3, 1 2 2)",