Skip to content

Commit b82dc61

Browse files
committed
feat: apply stratigraphic thickness to the map layer
Add a button to the stratigraphic column that writes a 'strat_thickness' field onto the selected units layer. The field holds the thickness of the matching unit in the column. Features that do not match a unit stay empty. The layer then gets a graduated renderer with one class for each thickness value, coloured with the selected colour ramp. Move the colour ramp lookup into a helper, so that the age and thickness styling use the same lookup.
1 parent b7c9a38 commit b82dc61

3 files changed

Lines changed: 221 additions & 6 deletions

File tree

‎loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,18 @@ def __init__(self, parent=None, data_manager=None):
175175
)
176176
applyAgeButton.clicked.connect(self.apply_age_to_layer)
177177
ageRow.addWidget(applyAgeButton)
178+
179+
applyThicknessButton = self._make_tool_button(
180+
"mActionMeasure.svg", "Apply Stratigraphic Thickness to Map Layer"
181+
)
182+
applyThicknessButton.setToolTip(
183+
"Apply Stratigraphic Thickness to Map Layer\n"
184+
"Write a 'strat_thickness' field (the thickness of each unit in the "
185+
"column) onto the selected layer above and style it with a graduated "
186+
"colour ramp."
187+
)
188+
applyThicknessButton.clicked.connect(self.apply_thickness_to_layer)
189+
ageRow.addWidget(applyThicknessButton)
178190
layout.addLayout(ageRow)
179191

180192
self._guess_units_layer()
@@ -564,6 +576,39 @@ def apply_age_to_layer(self):
564576
"units, or no features matched a stratigraphic unit.",
565577
)
566578

579+
def apply_thickness_to_layer(self):
580+
"""Write each unit's thickness onto the selected units layer and style it by a graduated ramp."""
581+
if not self.data_manager:
582+
print("Error: Data manager is not initialized.")
583+
return
584+
layer = self.unitsLayerComboBox.currentLayer()
585+
field_name = self.unitsLayerFieldComboBox.currentField()
586+
if layer is None or not field_name:
587+
QMessageBox.warning(
588+
self,
589+
"Apply Stratigraphic Thickness to Map Layer",
590+
"Please select a units layer and unit name field above.",
591+
)
592+
return
593+
ramp_name = self.strat_ageColorRampComboBox.currentText()
594+
applied = self.data_manager.apply_stratigraphic_thickness_to_layer(
595+
layer, field_name, ramp_name=ramp_name
596+
)
597+
if applied:
598+
QMessageBox.information(
599+
self,
600+
"Apply Stratigraphic Thickness to Map Layer",
601+
f"Applied stratigraphic thickness and graduated styling to layer "
602+
f"'{layer.name()}'.",
603+
)
604+
else:
605+
QMessageBox.warning(
606+
self,
607+
"Apply Stratigraphic Thickness to Map Layer",
608+
"Could not apply stratigraphic thickness. The stratigraphic column may "
609+
"have no units, or no features matched a stratigraphic unit.",
610+
)
611+
567612
def add_unit(self, *, unit_data=None, create_new=True):
568613
if unit_data is None:
569614
unit_data = {'type': 'unit', 'name': ''}

‎loopstructural/main/data_manager.py‎

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@
3131
from .vectorLayerWrapper import qgsLayerToGeoDataFrame
3232

3333

34+
def _lookup_colour_ramp(ramp_name):
35+
"""Return the named QGIS colour ramp, or the first available ramp if it is not found."""
36+
style = QgsStyle().defaultStyle()
37+
ramp = style.colorRamp(ramp_name) if ramp_name else None
38+
if ramp is None:
39+
ramp_names = style.colorRampNames()
40+
if ramp_names:
41+
ramp = style.colorRamp(ramp_names[0])
42+
return ramp
43+
44+
3445
def _colour_to_qcolor(colour):
3546
"""Convert a stratigraphic unit colour (hex string, colour name, or RGB tuple/array) to a QColor."""
3647
if colour is None:
@@ -619,12 +630,7 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None):
619630
)
620631
return False
621632

622-
style = QgsStyle().defaultStyle()
623-
ramp = style.colorRamp(ramp_name) if ramp_name else None
624-
if ramp is None:
625-
ramp_names = style.colorRampNames()
626-
if ramp_names:
627-
ramp = style.colorRamp(ramp_names[0])
633+
ramp = _lookup_colour_ramp(ramp_name)
628634

629635
n = len(unique_values)
630636
ranges = []
@@ -642,6 +648,114 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None):
642648
)
643649
return True
644650

651+
def apply_stratigraphic_thickness_to_layer(self, layer, field_name, ramp_name=None):
652+
"""Write each unit's thickness onto a layer and style it with a graduated colour ramp.
653+
654+
Writes a 'strat_thickness' field to ``layer`` holding the thickness
655+
set for the matching unit in the stratigraphic column (matched via
656+
``field_name``), then applies a graduated renderer over that field.
657+
Features that do not match a unit are left empty.
658+
659+
Parameters
660+
----------
661+
layer : QgsVectorLayer
662+
The layer to update (e.g. the geological units/geology layer).
663+
field_name : str
664+
Name of the field on ``layer`` holding the stratigraphic unit name.
665+
ramp_name : str, optional
666+
Name of a QGIS colour ramp (from QgsStyle) to use for the
667+
graduated renderer. Falls back to any available ramp if not found.
668+
669+
Returns
670+
-------
671+
bool
672+
True if the field was written and the renderer applied, False otherwise.
673+
"""
674+
if layer is None or not field_name:
675+
self.logger(
676+
message="No layer/unit name field set, cannot apply stratigraphic thickness."
677+
)
678+
return False
679+
if layer.fields().indexFromName(field_name) < 0:
680+
self.logger(message=f"Field '{field_name}' not found on layer '{layer.name()}'.")
681+
return False
682+
683+
thicknesses = {}
684+
for unit in self._stratigraphic_column.order:
685+
if unit.element_type != StratigraphicColumnElementType.UNIT:
686+
continue
687+
if unit.thickness is None:
688+
continue
689+
thicknesses[unit.name] = float(unit.thickness)
690+
if not thicknesses:
691+
self.logger(
692+
message="Stratigraphic column has no units, cannot apply stratigraphic thickness."
693+
)
694+
return False
695+
696+
thickness_field_name = "strat_thickness"
697+
try:
698+
from qgis.core import QgsField
699+
700+
from loopstructural.gui.compatibility import QVariantCompat
701+
702+
layer.startEditing()
703+
if layer.fields().indexFromName(thickness_field_name) < 0:
704+
layer.dataProvider().addAttributes(
705+
[QgsField(thickness_field_name, QVariantCompat.Double)]
706+
)
707+
layer.updateFields()
708+
thickness_index = layer.fields().indexFromName(thickness_field_name)
709+
for feature in layer.getFeatures():
710+
value = feature[field_name]
711+
unit_name = None if value is None else str(value).strip()
712+
layer.changeAttributeValue(
713+
feature.id(), thickness_index, thicknesses.get(unit_name)
714+
)
715+
if not layer.commitChanges():
716+
raise RuntimeError("; ".join(layer.commitErrors()))
717+
except Exception as err:
718+
layer.rollBack()
719+
self.logger(message=f"Failed to write stratigraphic thickness onto layer: {err}")
720+
return False
721+
722+
unique_values = set()
723+
for feature in layer.getFeatures():
724+
value = feature[thickness_field_name]
725+
if value is None or (hasattr(value, 'isNull') and value.isNull()):
726+
continue
727+
unique_values.add(float(value))
728+
unique_values = sorted(unique_values)
729+
730+
if not unique_values:
731+
self.logger(
732+
message="No features matched a stratigraphic unit, cannot style layer by thickness."
733+
)
734+
return False
735+
736+
ramp = _lookup_colour_ramp(ramp_name)
737+
738+
# One class per thickness value, with the class limits half-way
739+
# between adjacent values so that each value is in only one class.
740+
minimum = unique_values[0]
741+
span = unique_values[-1] - minimum
742+
ranges = []
743+
for i, value in enumerate(unique_values):
744+
lower = value if i == 0 else (unique_values[i - 1] + value) / 2
745+
upper = value if i == len(unique_values) - 1 else (value + unique_values[i + 1]) / 2
746+
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
747+
if ramp is not None:
748+
symbol.setColor(ramp.color((value - minimum) / span if span > 0 else 0))
749+
ranges.append(QgsRendererRange(lower, upper, symbol, f"{value:g}"))
750+
751+
layer.setRenderer(QgsGraduatedSymbolRenderer(thickness_field_name, ranges))
752+
layer.triggerRepaint()
753+
self.logger(
754+
message=f"Applied stratigraphic thickness field '{thickness_field_name}' and "
755+
f"graduated styling to layer '{layer.name()}'."
756+
)
757+
return True
758+
645759
def get_stratigraphic_unit_names(self):
646760
"""Get the names of the stratigraphic units in the column, youngest first.
647761
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Tests for painting unit thickness from the stratigraphic column onto a layer."""
2+
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
from qgis.core import QgsFeature, QgsGraduatedSymbolRenderer, QgsProject, QgsVectorLayer
7+
8+
from loopstructural.main.data_manager import ModellingDataManager
9+
10+
11+
@pytest.fixture
12+
def data_manager():
13+
data_manager = ModellingDataManager(
14+
project=QgsProject.instance(), mapCanvas=Mock(), logger=Mock()
15+
)
16+
data_manager._stratigraphic_column.clear()
17+
data_manager._stratigraphic_column.add_unit(name='A', colour=None, thickness=10.0)
18+
data_manager._stratigraphic_column.add_unit(name='B', colour=None, thickness=25.0)
19+
data_manager._stratigraphic_column.add_unit(name='C', colour=None, thickness=25.0)
20+
return data_manager
21+
22+
23+
@pytest.fixture
24+
def geology_layer():
25+
layer = QgsVectorLayer("Polygon?crs=EPSG:28350&field=UNITNAME:string", "geology", "memory")
26+
features = []
27+
for name in ['A', 'B', 'C', 'unknown']:
28+
feature = QgsFeature(layer.fields())
29+
feature['UNITNAME'] = name
30+
features.append(feature)
31+
layer.dataProvider().addFeatures(features)
32+
return layer
33+
34+
35+
def test_thickness_is_written_for_each_unit(data_manager, geology_layer):
36+
assert data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'UNITNAME')
37+
38+
values = {f['UNITNAME']: f['strat_thickness'] for f in geology_layer.getFeatures()}
39+
assert values['A'] == pytest.approx(10.0)
40+
assert values['B'] == pytest.approx(25.0)
41+
assert values['C'] == pytest.approx(25.0)
42+
unknown = values['unknown']
43+
assert unknown is None or (hasattr(unknown, 'isNull') and unknown.isNull())
44+
45+
46+
def test_layer_is_styled_with_one_class_per_thickness(data_manager, geology_layer):
47+
data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'UNITNAME')
48+
49+
renderer = geology_layer.renderer()
50+
assert isinstance(renderer, QgsGraduatedSymbolRenderer)
51+
assert renderer.classAttribute() == 'strat_thickness'
52+
assert [r.label() for r in renderer.ranges()] == ['10', '25']
53+
54+
55+
def test_missing_unit_name_field_is_refused(data_manager, geology_layer):
56+
assert not data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'NOPE')

0 commit comments

Comments
 (0)