From 5c81527d14d0f80edfa22493216082fe6e8a03c8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 13:26:14 -0300 Subject: [PATCH 1/2] 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 From ec4c329c551fdc03006e0dd913769d05c12fa5f4 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Fri, 30 Oct 2026 19:32:26 +1030 Subject: [PATCH 2/2] fix: review fixes for the model of faults only - Set the column of the model before the early return, so the model does not keep the units of the last build. - Correct valid: include the parametric faults, and check each fault trace, not only the last one. - Remove the second check of the unit names in names_to_refresh. - Add a test that builds parametric faults with an empty column. --- loopstructural/main/derived_refresh.py | 1 - loopstructural/main/model_manager.py | 30 +++++++++++++++----------- tests/qgis/test_parametric_fault.py | 14 ++++++++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/loopstructural/main/derived_refresh.py b/loopstructural/main/derived_refresh.py index 428dce8..126cc2f 100644 --- a/loopstructural/main/derived_refresh.py +++ b/loopstructural/main/derived_refresh.py @@ -41,7 +41,6 @@ def names_to_refresh(data_manager) -> List[str]: return names if ( data_manager.layer_roles.contacts_source == layer_roles.CONTACTS_FROM_GEOLOGY - and data_manager.get_stratigraphic_unit_names() and data_manager.get_layer_role(layer_roles.GEOLOGY) is not None and data_manager.get_layer_role(layer_roles.GEOLOGY_UNIT_FIELD) and derived.status(derived_data.BASAL_CONTACTS) != derived_data.CURRENT diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index fffcb7c..f87199e 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1106,6 +1106,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. """ + if self.stratigraphic_column is not None: + # also for an empty column, so the model does not keep the units + # of the last build + self.model.stratigraphic_column = self.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') @@ -1723,19 +1727,19 @@ def model_state(self) -> str: @property def valid(self): - valid = True - # 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 and len(self.faults) == 0: - valid = False - if len(self.faults) > 0: - for _fault_name, fault_data in self.faults.items(): - if 'data' in fault_data and not fault_data['data'].empty: - valid = True - else: - valid = False - return valid + """True if the model has stratigraphy or faults, and each fault trace has data. + + A model with faults only (traces or parametric faults) has no groups + and no stratigraphy. + """ + if any( + 'data' not in fault_data or fault_data['data'].empty + for fault_data in self.faults.values() + ): + return False + has_stratigraphy = len(self.groups) > 0 and len(self.stratigraphy) > 0 + has_faults = len(self.faults) > 0 or len(self.parametric_faults) > 0 + return has_stratigraphy or has_faults def update_model( self, diff --git a/tests/qgis/test_parametric_fault.py b/tests/qgis/test_parametric_fault.py index 3a84b48..f1a0758 100644 --- a/tests/qgis/test_parametric_fault.py +++ b/tests/qgis/test_parametric_fault.py @@ -62,6 +62,20 @@ def test_the_fault_is_built_with_constraints(self, manager): manager.update_model(notify_observers=False) assert 'F1' in _names(manager) + def test_a_model_of_faults_only_with_an_empty_column(self, manager): + column = StratigraphicColumn() + column.clear(basement=False) + manager.stratigraphic_column = column + manager.add_parametric_fault(_spec()) + manager.update_model(notify_observers=False) + manager.update_all_features(notify_observers=False) + assert 'F1' in _names(manager) + # no stratigraphic feature + assert all(name.startswith(('F1', '__')) for name in _names(manager)) + assert manager.model.stratigraphic_column is column + assert manager.valid + assert manager.model_state == 'solved' + def test_the_fault_surface_is_where_the_fault_is(self, manager): manager.add_parametric_fault(_spec()) manager.update_model(notify_observers=False)