Skip to content

Commit 061af66

Browse files
authored
Merge branch 'main' into feature/fault-domain-boundary
2 parents f617814 + e460b30 commit 061af66

2 files changed

Lines changed: 54 additions & 59 deletions

File tree

loopstructural/gui/map2loop_tools/sorter_widget.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def __init__(self, parent=None, data_manager=None, debug_manager=None):
7070
self.sortingAlgorithmComboBox.currentIndexChanged.connect(self._on_algorithm_changed)
7171
self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed)
7272
self.structureLayerComboBox.layerChanged.connect(self._on_structure_layer_changed)
73+
self.contactsLayerComboBox.layerChanged.connect(self._on_contacts_layer_changed)
7374
self.runButton.clicked.connect(self._run_sorter)
7475
self.orientationTypeComboBox.setCurrentIndex(1) # Default to Dip Direction
7576
self._guess_layers()
@@ -278,6 +279,12 @@ def _on_structure_layer_changed(self):
278279
if dipdir_match := matcher.find_match('DIPDIR'):
279280
self.dipDirFieldComboBox.setField(dipdir_match)
280281

282+
def _on_contacts_layer_changed(self):
283+
"""Update field combo boxes when contacts layer changes."""
284+
layer = self.contactsLayerComboBox.currentLayer()
285+
self.unitName1FieldComboBox.setLayer(layer)
286+
self.unitName2FieldComboBox.setLayer(layer)
287+
281288
def _on_algorithm_changed(self):
282289
"""Update UI based on selected sorting algorithm and map2loop requirements."""
283290
algorithm_index = self.sortingAlgorithmComboBox.currentIndex()

tests/qgis/test_stratigraphic_value_consistency.py

Lines changed: 47 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,37 @@
1-
"""Regression test for the basal-contact training-value direction.
1+
"""Regression test for the training-value / isovalue direction bug.
22
33
`GeologicalModelManager.update_foliation_features` assigns a scalar `val` to
44
each unit's basal contact before handing the data to the interpolator.
5-
`StratigraphicColumn.update_unit_values` (LoopStructural core) computes each
6-
unit's `min()`/`max()` by walking the column youngest-to-oldest, accumulating
7-
thickness from 0 -- forced by the fact that a basement unit's open-ended
8-
range (`thickness=inf`) only works when it's the *last* unit processed in
9-
that walk (an infinite thickness earlier would poison every unit after it).
10-
That makes `u.min()` the boundary shared with the next *younger* neighbour
11-
(a unit's top) and `u.max()` the boundary shared with the next *older*
12-
neighbour (a unit's true base).
13-
14-
A digitised "basal contact" represents a unit's base, so it must be trained
15-
at `val = u.max()`, not `u.min()`. Training at `u.min()` (the old behaviour)
16-
anchors every unit's own contact points to its top boundary instead of its
17-
base -- confirmed on a live project: every unit's own mapped points
18-
evaluated into its next-younger neighbour's bracket instead of its own,
19-
e.g. "Formacao Betari"'s own contact data landing inside "Formacao
20-
Guaricanga"'s value range.
21-
22-
Note `get_isovalues()` also reports `u.min()` per unit -- that's a separate
23-
concern (naming which unit an *extracted isosurface* belongs to), not a
24-
statement about which value basal-contact training data should use, so this
25-
test does not compare against it.
26-
27-
This direction has flipped back and forth as this plugin and LoopStructural
28-
evolved independently -- see the "stratigraphic column was reversed" fixes
29-
in model_manager.py (2025-07-21) and the widget (2025-08-21, reverted
30-
2025-09-08). This test pins the invariant so a future change fails loudly
31-
here instead of silently inverting a user's model.
5+
`StratigraphicColumn.get_isovalues` (LoopStructural core) later decides which
6+
name to stamp on each extracted isosurface, using its own idea of which
7+
value belongs to which unit.
8+
9+
10+
Both walk `reversed(group.units)`, accumulating cumulative thickness the
11+
same way, so a unit's own training value must equal `u.min()` -- the
12+
cumulative thickness *before* that unit's own thickness is added. This is
13+
also each unit's true base: `add_unit(..., where='top')` (the default)
14+
appends to the end of the column, so building a column correctly means
15+
adding the truly oldest unit first and progressively younger ones after --
16+
each unit's own base is the boundary shared with the next-older neighbour
17+
processed just before it, i.e. `min()`. See LoopStructural's own
18+
`test_get_isovalues_multi_unit_group` (`tests/unit/modelling/
19+
test_stratigraphic_column.py`), whose comment states this explicitly: "the
20+
base of the oldest unit in a group is 0".
21+
22+
If training and `get_isovalues()` disagree on this, every extracted surface
23+
gets labelled with the wrong unit while keeping correct geometry -- see the
24+
"stratigraphic column was reversed" fixes in model_manager.py (2025-07-21)
25+
and the widget (2025-08-21, reverted 2025-09-08). This has flipped back and
26+
forth as this plugin and LoopStructural evolved independently; this test
27+
pins the invariant so a future change on either side fails loudly here
28+
instead of silently inverting a user's model.
29+
30+
Note this is a separate concern from whether a stratigraphic column's units
31+
were themselves *added* in the correct oldest-to-youngest order -- if they
32+
weren't, `min()`/`max()` stop corresponding to true geological base/top no
33+
matter what training does, and the fix is to reorder the column's units,
34+
not to change which value training uses.
3235
"""
3336

3437
import pandas as pd
@@ -61,7 +64,7 @@ def fake_create_and_add_foliation(name, data=None, **kwargs):
6164
return manager
6265

6366

64-
class TestTrainingValueIsUnitsOwnBase:
67+
class TestTrainingValueMatchesIsovalue:
6568
def test_single_group_three_units(self, manager):
6669
column = StratigraphicColumn()
6770
column.clear(basement=False) # single flat group, no unconformities
@@ -76,14 +79,14 @@ def test_single_group_three_units(self, manager):
7679
manager.update_foliation_features()
7780

7881
training_values = self._training_values_by_unit(manager._captured_calls)
79-
expected_values = self._own_base_by_unit(column, ('oldest', 'middle', 'youngest'))
82+
expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()}
8083

8184
for unit_name in ('oldest', 'middle', 'youngest'):
8285
assert training_values[unit_name] == pytest.approx(expected_values[unit_name]), (
83-
f"'{unit_name}' was trained with val={training_values[unit_name]} but its "
84-
f"own base (boundary with the next-older neighbour) is "
85-
f"{expected_values[unit_name]} -- basal-contact data must train at a unit's "
86-
f"own max(), not min(), or extracted surfaces get the wrong unit name."
86+
f"'{unit_name}' was trained with val={training_values[unit_name]} but "
87+
f"get_isovalues() will label the value={expected_values[unit_name]} surface "
88+
f"with this unit's name -- the trained field and the isosurface labels "
89+
f"disagree on direction, so extracted surfaces will get the wrong unit name."
8790
)
8891

8992
def test_two_groups_split_by_unconformity(self, manager):
@@ -102,9 +105,7 @@ def test_two_groups_split_by_unconformity(self, manager):
102105
manager.update_foliation_features()
103106

104107
training_values = self._training_values_by_unit(manager._captured_calls)
105-
expected_values = self._own_base_by_unit(
106-
column, ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper')
107-
)
108+
expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()}
108109

109110
for unit_name in ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper'):
110111
assert training_values[unit_name] == pytest.approx(expected_values[unit_name])
@@ -115,19 +116,14 @@ def test_undigitised_unit_does_not_shift_later_units_in_group(self, manager):
115116
must still contribute its own thickness to `val` for every unit
116117
that follows it in the group -- `update_foliation_features` used to
117118
`continue` past an undigitised unit before accumulating its
118-
thickness, which shifted every later unit's trained value by that
119-
unit's thickness relative to its own true base.
119+
thickness, which shifted every later unit's trained value relative
120+
to what `get_isovalues()` expects.
120121
"""
121122
column = StratigraphicColumn()
122123
column.clear(basement=False)
123-
# Added first so it ends up last in `group.units` (each `add_unit`
124-
# prepends) and therefore *first* in the `reversed(group.units)`
125-
# build loop -- matching the live project, where the undigitised
126-
# unit was the one whose skipped increment shifted every unit
127-
# after it.
128-
column.add_unit(name='Top', thickness=999.0, where='top')
129-
column.add_unit(name='basin_fill', thickness=150.0, where='top')
130124
column.add_unit(name='basin_floor', thickness=50.0, where='top')
125+
column.add_unit(name='basin_fill', thickness=150.0, where='top')
126+
column.add_unit(name='Top', thickness=999.0, where='top')
131127

132128
manager.stratigraphic_column = column
133129
for name in ('basin_floor', 'basin_fill'):
@@ -137,24 +133,16 @@ def test_undigitised_unit_does_not_shift_later_units_in_group(self, manager):
137133
manager.update_foliation_features()
138134

139135
training_values = self._training_values_by_unit(manager._captured_calls)
140-
expected_values = self._own_base_by_unit(column, ('basin_floor', 'basin_fill'))
136+
expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()}
141137

142138
for unit_name in ('basin_floor', 'basin_fill'):
143139
assert training_values[unit_name] == pytest.approx(expected_values[unit_name]), (
144-
f"'{unit_name}' was trained with val={training_values[unit_name]} but its "
145-
f"own base is {expected_values[unit_name]} -- an undigitised unit earlier in "
146-
f"the group must still shift later units' trained values by its own thickness."
140+
f"'{unit_name}' was trained with val={training_values[unit_name]} but "
141+
f"get_isovalues() expects value={expected_values[unit_name]} -- an "
142+
f"undigitised unit earlier in the group must still shift later units' "
143+
f"trained values by its own thickness."
147144
)
148145

149-
@staticmethod
150-
def _own_base_by_unit(column, unit_names):
151-
"""Each unit's own base: the boundary with the next-*older* neighbour,
152-
i.e. `u.max()` -- see module docstring for why max() (not min(), which
153-
`get_isovalues()` reports) is the correct target for basal-contact
154-
training data."""
155-
units_by_name = {u.name: u for group in column.get_groups() for u in group.units}
156-
return {name: units_by_name[name].max() for name in unit_names}
157-
158146
@staticmethod
159147
def _training_values_by_unit(captured_calls):
160148
combined = pd.concat(captured_calls, ignore_index=True)

0 commit comments

Comments
 (0)