|
| 1 | +"""Regression test for the training-value / isovalue direction bug. |
| 2 | +
|
| 3 | +`GeologicalModelManager.update_foliation_features` assigns a scalar `val` to |
| 4 | +each unit's basal contact before handing the data to the interpolator. |
| 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. |
| 35 | +""" |
| 36 | + |
| 37 | +import pandas as pd |
| 38 | +import pytest |
| 39 | +from LoopStructural import StratigraphicColumn |
| 40 | + |
| 41 | +from loopstructural.main.model_manager import GeologicalModelManager |
| 42 | + |
| 43 | + |
| 44 | +def _contact(unit_name): |
| 45 | + """A minimal single-point basal contact, tagged with its unit name so |
| 46 | + the test can recover which row came from which unit after the group |
| 47 | + DataFrames get concatenated.""" |
| 48 | + return pd.DataFrame({'X': [0.0], 'Y': [0.0], 'Z': [0.0], 'source_unit': [unit_name]}) |
| 49 | + |
| 50 | + |
| 51 | +@pytest.fixture |
| 52 | +def manager(monkeypatch): |
| 53 | + manager = GeologicalModelManager() |
| 54 | + |
| 55 | + captured_calls = [] |
| 56 | + |
| 57 | + def fake_create_and_add_foliation(name, data=None, **kwargs): |
| 58 | + captured_calls.append(data) |
| 59 | + return object() # stand-in foliation, only passed back into add_unconformity |
| 60 | + |
| 61 | + monkeypatch.setattr(manager.model, 'create_and_add_foliation', fake_create_and_add_foliation) |
| 62 | + monkeypatch.setattr(manager.model, 'add_unconformity', lambda *a, **k: None) |
| 63 | + manager._captured_calls = captured_calls |
| 64 | + return manager |
| 65 | + |
| 66 | + |
| 67 | +class TestTrainingValueMatchesIsovalue: |
| 68 | + def test_single_group_three_units(self, manager): |
| 69 | + column = StratigraphicColumn() |
| 70 | + column.clear(basement=False) # single flat group, no unconformities |
| 71 | + column.add_unit(name='oldest', thickness=100.0, where='top') |
| 72 | + column.add_unit(name='middle', thickness=200.0, where='top') |
| 73 | + column.add_unit(name='youngest', thickness=300.0, where='top') |
| 74 | + |
| 75 | + manager.stratigraphic_column = column |
| 76 | + for name in ('oldest', 'middle', 'youngest'): |
| 77 | + manager.stratigraphy[name]['contact'] = _contact(name) |
| 78 | + |
| 79 | + manager.update_foliation_features() |
| 80 | + |
| 81 | + training_values = self._training_values_by_unit(manager._captured_calls) |
| 82 | + expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()} |
| 83 | + |
| 84 | + for unit_name in ('oldest', 'middle', 'youngest'): |
| 85 | + assert training_values[unit_name] == pytest.approx(expected_values[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." |
| 90 | + ) |
| 91 | + |
| 92 | + def test_two_groups_split_by_unconformity(self, manager): |
| 93 | + column = StratigraphicColumn() |
| 94 | + column.clear(basement=False) |
| 95 | + column.add_unit(name='basin_floor', thickness=50.0, where='top') |
| 96 | + column.add_unit(name='basin_fill', thickness=150.0, where='top') |
| 97 | + column.add_unconformity(name='regional_unconformity', where='top') |
| 98 | + column.add_unit(name='cover_lower', thickness=80.0, where='top') |
| 99 | + column.add_unit(name='cover_upper', thickness=120.0, where='top') |
| 100 | + |
| 101 | + manager.stratigraphic_column = column |
| 102 | + for name in ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper'): |
| 103 | + manager.stratigraphy[name]['contact'] = _contact(name) |
| 104 | + |
| 105 | + manager.update_foliation_features() |
| 106 | + |
| 107 | + training_values = self._training_values_by_unit(manager._captured_calls) |
| 108 | + expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()} |
| 109 | + |
| 110 | + for unit_name in ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper'): |
| 111 | + assert training_values[unit_name] == pytest.approx(expected_values[unit_name]) |
| 112 | + |
| 113 | + def test_undigitised_unit_does_not_shift_later_units_in_group(self, manager): |
| 114 | + """Regression test for a real bug: a unit with no digitised contact |
| 115 | + or orientation data (e.g. a "Top" unit nobody has mapped points for) |
| 116 | + must still contribute its own thickness to `val` for every unit |
| 117 | + that follows it in the group -- `update_foliation_features` used to |
| 118 | + `continue` past an undigitised unit before accumulating its |
| 119 | + thickness, which shifted every later unit's trained value relative |
| 120 | + to what `get_isovalues()` expects. |
| 121 | + """ |
| 122 | + column = StratigraphicColumn() |
| 123 | + column.clear(basement=False) |
| 124 | + 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') |
| 127 | + |
| 128 | + manager.stratigraphic_column = column |
| 129 | + for name in ('basin_floor', 'basin_fill'): |
| 130 | + manager.stratigraphy[name]['contact'] = _contact(name) |
| 131 | + # 'Top' deliberately has no entry in manager.stratigraphy at all. |
| 132 | + |
| 133 | + manager.update_foliation_features() |
| 134 | + |
| 135 | + training_values = self._training_values_by_unit(manager._captured_calls) |
| 136 | + expected_values = {name: entry['value'] for name, entry in column.get_isovalues().items()} |
| 137 | + |
| 138 | + for unit_name in ('basin_floor', 'basin_fill'): |
| 139 | + assert training_values[unit_name] == pytest.approx(expected_values[unit_name]), ( |
| 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." |
| 144 | + ) |
| 145 | + |
| 146 | + @staticmethod |
| 147 | + def _training_values_by_unit(captured_calls): |
| 148 | + combined = pd.concat(captured_calls, ignore_index=True) |
| 149 | + return dict(zip(combined['source_unit'], combined['val'])) |
0 commit comments