From 5c81527d14d0f80edfa22493216082fe6e8a03c8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 13:26:14 -0300 Subject: [PATCH] feat: build a model with only faults In the map workflow, an empty stratigraphic column no longer needs the geology layer, the structure layer or basal contacts. The build skips the contacts and thickness calculation and the stratigraphic feature, and builds the faults. Step 5 does not offer the stratigraphic surfaces or the block model when the model has only faults. --- loopstructural/gui/modelling/steps/checks.py | 11 +++++++--- .../gui/modelling/steps/export_panel.py | 21 +++++++++++++++++++ loopstructural/main/derived_refresh.py | 5 +++++ loopstructural/main/model_manager.py | 9 ++++++-- tests/unit/test_build_plan.py | 7 +++++++ tests/unit/test_step_checks.py | 9 +++++++- 6 files changed, 56 insertions(+), 6 deletions(-) diff --git a/loopstructural/gui/modelling/steps/checks.py b/loopstructural/gui/modelling/steps/checks.py index c7ad052..de0a374 100644 --- a/loopstructural/gui/modelling/steps/checks.py +++ b/loopstructural/gui/modelling/steps/checks.py @@ -66,13 +66,18 @@ def check_data(data_manager, model_manager=None) -> StepCheck: def check_stratigraphy(data_manager, model_manager=None) -> StepCheck: """Step 2: the column and the results that come from it.""" problems, todo = [], [] + unit_names = data_manager.get_stratigraphic_unit_names() + if not unit_names: + # The step is optional: a model of faults only has no column + todo.append( + "Add units to the stratigraphic column. This step is optional if you model " + "only faults." + ) + return StepCheck(tuple(problems), tuple(todo)) if data_manager.get_layer_role(layer_roles.GEOLOGY) is None: todo.append("Select the geology layer.") if data_manager.get_layer_role(layer_roles.STRUCTURE) is None: todo.append("Select the structure layer.") - unit_names = data_manager.get_stratigraphic_unit_names() - if not unit_names: - todo.append("Add units to the stratigraphic column.") problems.extend(_out_of_date_messages(data_manager)) contact_units = set(data_manager.get_unique_basal_units()) diff --git a/loopstructural/gui/modelling/steps/export_panel.py b/loopstructural/gui/modelling/steps/export_panel.py index e9f8959..420f47f 100644 --- a/loopstructural/gui/modelling/steps/export_panel.py +++ b/loopstructural/gui/modelling/steps/export_panel.py @@ -169,6 +169,27 @@ def set_model_solved(self, solved): ) for tab_index in range(self.tabs.count()): self.tabs.widget(tab_index).setEnabled(self._model_solved and self._task is None) + # A model of faults only has no units: no stratigraphic surfaces, no block model + has_units = not solved or self._has_stratigraphic_features() + if has_units and not self.surface_strat.isEnabled(): + self.surface_strat.setChecked(True) + self.surface_strat.setEnabled(has_units) + if not has_units: + self.surface_strat.setChecked(False) + self.tabs.setTabEnabled(1, has_units) + if not has_units: + self.hint.setText("The model has faults only, so the block model is not offered.") + + def _has_stratigraphic_features(self): + """Return False if every feature of the model is a fault.""" + manager = self.model_manager + if manager is None: + return True + faults = set(getattr(manager, 'faults', {}) or {}) | set( + getattr(manager, 'parametric_faults', {}) or {} + ) + names = [f.name for f in manager.features() if not f.name.startswith('__')] + return any(name not in faults for name in names) # -- actions ------------------------------------------------------------ diff --git a/loopstructural/main/derived_refresh.py b/loopstructural/main/derived_refresh.py index f8c5386..0ca39ed 100644 --- a/loopstructural/main/derived_refresh.py +++ b/loopstructural/main/derived_refresh.py @@ -31,9 +31,14 @@ def names_to_refresh(data_manager) -> List[str]: because the layer is an input of the user. - Thickness: only when it is out of date. A thickness that was never calculated can be typed, so the build does not calculate it. + + With no units in the column (a model of faults only), nothing comes from + the column, so the list is empty. """ derived = data_manager.derived names = [] + if not data_manager.get_stratigraphic_unit_names(): + return names if ( data_manager.layer_roles.contacts_source == layer_roles.CONTACTS_FROM_GEOLOGY and data_manager.get_stratigraphic_unit_names() diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index cc59778..35fa5e1 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1077,6 +1077,10 @@ def update_foliation_features(self): and the basal contacts and structural orientations data. This method will automatically add unconformities based on the stratigraphic column. """ + # A model with faults only has no column, or a column with no units + if self.stratigraphic_column is None or not self.stratigraphic_column.get_groups(): + self._emit('foliation_features_updated') + return stratigraphic_column = {} # Take each unit's training value straight from `get_isovalues()` # (LoopStructural core), which is what labels the extracted @@ -1680,9 +1684,10 @@ def model_state(self) -> str: @property def valid(self): valid = True - if len(self.groups) == 0: + # A model with faults only has no groups and no stratigraphy + if len(self.groups) == 0 and len(self.faults) == 0: valid = False - if len(self.stratigraphy) == 0: + if len(self.stratigraphy) == 0 and len(self.faults) == 0: valid = False if len(self.faults) > 0: for _fault_name, fault_data in self.faults.items(): diff --git a/tests/unit/test_build_plan.py b/tests/unit/test_build_plan.py index 0743959..357611c 100644 --- a/tests/unit/test_build_plan.py +++ b/tests/unit/test_build_plan.py @@ -176,6 +176,13 @@ def test_no_column_calculates_no_contacts(self, dm): dm.units = [] assert names_to_refresh(dm) == [] + def test_no_column_calculates_no_thickness_even_if_out_of_date(self, dm): + dm.derived.record(derived_data.BASAL_CONTACTS) + dm.derived.record(derived_data.THICKNESS) + dm.inputs = {'unit_order': []} + dm.units = [] + assert names_to_refresh(dm) == [] + def test_no_geology_layer_calculates_no_contacts(self, dm): dm.layer_roles.values[layer_roles.GEOLOGY] = None assert names_to_refresh(dm) == [] diff --git a/tests/unit/test_step_checks.py b/tests/unit/test_step_checks.py index f771eb6..eb3c600 100644 --- a/tests/unit/test_step_checks.py +++ b/tests/unit/test_step_checks.py @@ -115,9 +115,16 @@ def map_layers(self, dm, request): def test_an_empty_column_is_not_started(self, dm): check = checks.check_stratigraphy(dm) assert check.status == Status.NOT_STARTED - assert 'Add units to the stratigraphic column.' in check.todo + assert any(t.startswith('Add units to the stratigraphic column.') for t in check.todo) + assert 'optional' in check.todo[0] + + def test_an_empty_column_does_not_ask_for_layers_or_contacts(self, dm): + todo = checks.check_stratigraphy(dm).todo + assert len(todo) == 1 + assert not checks.check_stratigraphy(dm).problems def test_missing_map_layers_are_a_todo(self, dm): + dm.units = ['a'] todo = checks.check_stratigraphy(dm).todo assert 'Select the geology layer.' in todo assert 'Select the structure layer.' in todo