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
44each 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
3437import 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