From d1590700516f03874f6e3150a19b3c2c624cdd35 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 10:53:43 -0300 Subject: [PATCH 1/2] feat: separate the map and constraints workflows With "Interpolate surfaces from constraints", the build now uses only the features that the user adds in step 4. The column, the contacts and the fault traces keep their data, but the model does not use it. - The model manager has the workflow mode. update_model and refresh_feature_data skip the column and the fault traces, and a column or topology change does not make the model stale. - The data manager does not watch or check the map layers in this mode. - The geology and structure layer checks move to the stratigraphy step. - The build button and step 4 name the workflow. --- docs/development/usability-plan.md | 16 +- .../geological_model_tab.py | 9 + .../gui/modelling/steps/build_plan.py | 16 +- loopstructural/gui/modelling/steps/checks.py | 8 +- loopstructural/main/data_manager.py | 48 +++++- loopstructural/main/model_manager.py | 67 +++++-- tests/qgis/test_workflow_mode_model.py | 163 ++++++++++++++++++ tests/unit/test_build_plan.py | 28 +++ tests/unit/test_step_checks.py | 22 ++- 9 files changed, 339 insertions(+), 38 deletions(-) create mode 100644 tests/qgis/test_workflow_mode_model.py diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index c199fb1..86db1da 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -369,26 +369,26 @@ Rules: Tasks: -- [ ] Give the model manager the workflow mode. `update_model` skips +- [x] Give the model manager the workflow mode. `update_model` skips `update_fault_features` (trace faults only), `update_foliation_features` and the generated-feature data in the "constraints" mode. It still builds the parametric faults and the manual foliations. -- [ ] `refresh_feature_data` and `model_state` ignore the column, the contacts +- [x] `refresh_feature_data` and `model_state` ignore the column, the contacts and the fault traces in the "constraints" mode. A change to the column does not make the model stale. -- [ ] The data manager does not watch, reload or check the map input layers +- [x] The data manager does not watch, reload or check the map input layers (contacts, structure, fault traces) in the "constraints" mode. This stops "Update data and solve" and the "Input layers changed" problem for layers that the model does not use. `get_layers_outside_bounding_box` uses the same rule. -- [ ] `sync_extra_constraints` and the read-only "processed" rows are empty in +- [x] `sync_extra_constraints` and the read-only "processed" rows are empty in the "constraints" mode. -- [ ] Step checks: `check_data` does not ask for the geology and structure +- [x] Step checks: `check_data` does not ask for the geology and structure layers in the "constraints" mode. Move these two items to `check_stratigraphy` (see 6.2). -- [ ] The primary button text and tooltip name the workflow ("Build model from +- [x] The primary button text and tooltip name the workflow ("Build model from constraints"). -- [ ] Save the choice with the state (done in phase 4). Load old state files +- [x] Save the choice with the state (done in phase 4). Load old state files with the "map" mode. Acceptance: a user has a map project with a column, contacts and faults. The @@ -429,7 +429,7 @@ Tasks: - [ ] Keep the geology picker of the column group in sync with the new picker (both use the same roles). Decide in the review if one of them must be removed (see the open questions). -- [ ] Move the "Select the geology layer" and "Select the structure layer" +- [x] Move the "Select the geology layer" and "Select the structure layer" items from `check_data` to `check_stratigraphy`. - [ ] Update the text that says "in step 1" for these layers (`derived_refresh.py`, `checks.py`, `pages.py`, the docs). diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index 6686e39..f7b81c4 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -192,6 +192,13 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): self.problemsLabel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) mainLayout.insertWidget(1, self.problemsLabel, 0) self.problemsLabel.hide() + # With "constraints", the build does not use the column or the map + # layers. This tells the user why a unit of the column is not in the model. + self.modeNoteLabel = QLabel("The column and the map layers are not used in this mode.") + self.modeNoteLabel.setWordWrap(True) + self.modeNoteLabel.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) + mainLayout.insertWidget(2, self.modeNoteLabel, 0) + self.modeNoteLabel.hide() self._problems_provider = None if self.data_manager is not None: self.data_manager.add_layer_data_changed_callback(self.refresh_primary_action) @@ -297,8 +304,10 @@ def refresh_primary_action(self, *args, **kwargs): derived_out_of_date=self._derived_to_refresh(), layers_changed=bool(changed), blocked_reason=self._blocked_reason(), + workflow_mode=self._workflow_mode(), ) action = self._primary_action + self.modeNoteLabel.setVisible(self._workflow_mode() == WORKFLOW_MODE_CONSTRAINTS) self.primaryButton.setText(action.text) self.primaryButton.setToolTip(action.tooltip) if not self._task_running: diff --git a/loopstructural/gui/modelling/steps/build_plan.py b/loopstructural/gui/modelling/steps/build_plan.py index 34899a0..000d697 100644 --- a/loopstructural/gui/modelling/steps/build_plan.py +++ b/loopstructural/gui/modelling/steps/build_plan.py @@ -11,6 +11,7 @@ from loopstructural.main.workflow_mode import ( CONSTRAINT_MODE_HIDDEN_STEPS, WORKFLOW_MODE_CONSTRAINTS, + WORKFLOW_MODE_MAP, ) # The actions of the primary button @@ -34,6 +35,7 @@ def choose_primary_action( derived_out_of_date=(), layers_changed=False, blocked_reason=None, + workflow_mode=WORKFLOW_MODE_MAP, ) -> PrimaryAction: """Return the action of the primary button. @@ -47,13 +49,21 @@ def choose_primary_action( True if an input layer changed after the model data was read. blocked_reason : str, optional A problem that stops every build, for example no bounding box. + workflow_mode : str + The start choice of the user. The text and the tooltip name it. """ + constraints = workflow_mode == WORKFLOW_MODE_CONSTRAINTS + build_text = "Build model from constraints" if constraints else "Build model" + inputs = "the constraints" if constraints else "the column and the map layers" if blocked_reason: - return PrimaryAction(ACTION_NONE, "Build model", False, blocked_reason) - derived_out_of_date = list(derived_out_of_date) + return PrimaryAction(ACTION_NONE, build_text, False, blocked_reason) + # With constraints only, no result comes from the column + derived_out_of_date = [] if constraints else list(derived_out_of_date) if model_state == 'empty': return PrimaryAction( - ACTION_BUILD, "Build model", tooltip="Make the features and solve them." + ACTION_BUILD, + build_text, + tooltip=f"Make the features from {inputs} and solve them.", ) if model_state == 'stale' or derived_out_of_date: if derived_out_of_date: diff --git a/loopstructural/gui/modelling/steps/checks.py b/loopstructural/gui/modelling/steps/checks.py index 8159e0c..c7ad052 100644 --- a/loopstructural/gui/modelling/steps/checks.py +++ b/loopstructural/gui/modelling/steps/checks.py @@ -60,16 +60,16 @@ def check_data(data_manager, model_manager=None) -> StepCheck: todo.append("Set the bounding box.") if not data_manager.is_model_crs_valid(): problems.append("The model CRS must be a projected CRS (in metres).") - 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.") return StepCheck(tuple(problems), tuple(todo)) def check_stratigraphy(data_manager, model_manager=None) -> StepCheck: """Step 2: the column and the results that come from it.""" problems, 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.") diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index fceda2d..7e7fd7d 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -33,7 +33,7 @@ from .layer_roles import LayerRoles from .m2l_api import paint_stratigraphic_order from .vectorLayerWrapper import qgsLayerToGeoDataFrame -from .workflow_mode import WORKFLOW_MODE_MAP, WORKFLOW_MODES +from .workflow_mode import WORKFLOW_MODE_CONSTRAINTS, WORKFLOW_MODE_MAP, WORKFLOW_MODES def _lookup_colour_ramp(ramp_name): @@ -214,6 +214,7 @@ def set_model_manager(self, model_manager): self._model_manager.set_fault_boundaries(self._fault_boundaries) self._model_manager.set_flipped_fault_boundaries(self._flipped_fault_boundaries) self._model_manager.update_bounding_box(self._bounding_box) + self._model_manager.set_workflow_mode(self.workflow_mode) def set_bounding_box( self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None, *, mark_set=True @@ -312,9 +313,31 @@ def set_workflow_mode(self, mode): raise ValueError(f"Unknown workflow mode '{mode}'.") if mode != self.workflow_mode: self.workflow_mode = mode + if self._model_manager is not None: + self._model_manager.set_workflow_mode(mode) + if mode != WORKFLOW_MODE_CONSTRAINTS: + self._read_map_data_again() + # The layers that the model reads depend on the mode + self._changed_layer_ids &= set(self._layers_to_watch()) + self.refresh_layer_watchers() + self._sync_processed_feature_data() for callback in list(self._workflow_mode_callbacks): callback(mode) + def _read_map_data_again(self): + """Read the map layers again, after the mode changed back to "map". + + The layers were not watched in the "constraints" mode, so an edit + made there is not in the model manager. + """ + try: + if self._basal_contacts is not None or self._structural_orientations is not None: + self.update_stratigraphy() + if self._fault_traces is not None and self._fault_traces.get('layer') is not None: + self.update_faults() + except Exception as e: + self.logger(message=f"Could not read the map layers again: {e}", log_level=2) + def add_workflow_mode_callback(self, callback): """Call ``callback(mode)`` each time the start choice changes.""" if callback not in self._workflow_mode_callbacks: @@ -1223,7 +1246,13 @@ def fault_spans_model_domain(self, fault_name, *, tolerance=0.0): def get_input_layers(self): """Return a dict of {role: layer} for every input layer currently - configured (basal contacts, fault traces, structural orientations).""" + configured (basal contacts, fault traces, structural orientations). + + With "Interpolate surfaces from constraints", the model does not use + these layers, so the result is empty. + """ + if self.workflow_mode == WORKFLOW_MODE_CONSTRAINTS: + return {} layers = {} for role, config in ( ('Basal contacts', self._basal_contacts), @@ -1264,10 +1293,14 @@ def _manual_foliation_layer_rows(self): def _layers_to_watch(self): """Return {layer id: layer} for every layer the model reads data from.""" layers = list(self.get_input_layers().values()) - for entries in self.feature_data.values(): - for entry in entries.values(): - if not entry.get('processed') and entry.get('layer') is not None: - layers.append(entry['layer']) + # The rows that the user added to a generated feature are not used with + # "constraints". The rows of the manual foliations come from + # `_manual_foliation_layer_rows`. + if self.workflow_mode != WORKFLOW_MODE_CONSTRAINTS: + for entries in self.feature_data.values(): + for entry in entries.values(): + if not entry.get('processed') and entry.get('layer') is not None: + layers.append(entry['layer']) layers.extend(layer for layer, _ in self._manual_foliation_layer_rows()) watched = {} for layer in layers: @@ -1655,7 +1688,8 @@ def _sync_processed_feature_data(self): for key in [k for k, v in entries.items() if v.get('processed')]: del entries[key] - if self._model_manager is None: + # With "constraints", the model does not use the column or the traces + if self._model_manager is None or self.workflow_mode == WORKFLOW_MODE_CONSTRAINTS: return if self._stratigraphic_column is not None: diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 3a79f3b..a241295 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -32,6 +32,7 @@ from ..main import constraints, parametric_fault from ..main.data_types import FaultEntry, StratigraphyEntry from ..main.helpers import qgisAttributeIsNone +from ..main.workflow_mode import WORKFLOW_MODE_CONSTRAINTS, WORKFLOW_MODE_MAP, WORKFLOW_MODES class ModelSolveCancelled(Exception): @@ -233,6 +234,29 @@ def __init__(self, debug_manager=None): # Set by request_cancel() and checked in _report_progress; lets a # running Initialize/Solve be stopped between fault/feature builds. self._cancel_requested = False + # How the user builds the model; see `set_workflow_mode`. + self.workflow_mode = WORKFLOW_MODE_MAP + + @property + def uses_map_data(self) -> bool: + """True if the model has the features that the column and the fault traces make.""" + return self.workflow_mode != WORKFLOW_MODE_CONSTRAINTS + + def set_workflow_mode(self, mode): + """Set how the user builds the model. + + With "constraints", the model has only the features that the user + added (foliations, unconformities and parametric faults). The column + and the fault traces keep their data, but the build does not use it. + A change of the mode makes the features of a built model out of date. + """ + if mode not in WORKFLOW_MODES: + raise ValueError(f"Unknown workflow mode '{mode}'.") + if mode == self.workflow_mode: + return + self.workflow_mode = mode + if self.features(): + self.mark_column_changed() def request_cancel(self): """Ask a running `update_model`/`update_all_features` call to stop. @@ -405,6 +429,9 @@ def _on_stratigraphic_column_changed(self, _observable, event, *args, **kwargs): A change to a unit only (its name, thickness or colour) does not change the groups, so `refresh_feature_data` can apply it. """ + if not self.uses_map_data: + # the build does not use the column in this mode + return requires_reinit = event in self._COLUMN_EVENTS_REQUIRING_REINIT or ( event == 'element_updated' and isinstance(kwargs.get('element'), StratigraphicUnconformity) @@ -468,7 +495,8 @@ def _on_fault_topology_changed(self, _observable, event, *args, **kwargs): event in self._TOPOLOGY_EVENTS_REQUIRING_REINIT or new_relationship is FaultRelationshipType.FAULTED ) - if requires_reinit: + # the fault traces are not used with "constraints" + if requires_reinit and self.uses_map_data: self._topology_dirty = True names = { payload.get(key) @@ -518,7 +546,8 @@ def batch_fault_topology_updates(self): # relationships, which -- like any single FAULTED/structural # edit -- needs Initialize Model re-run before Solve Model # picks it up; see `_TOPOLOGY_EVENTS_REQUIRING_REINIT`. - self._topology_dirty = True + if self.uses_map_data: + self._topology_dirty = True self._emit('model_updated') def update_bounding_box(self, bounding_box: BoundingBox): @@ -1128,8 +1157,11 @@ def _extra_kwargs(self, name) -> dict: # -- detach a generated feature --------------------------------------- def generated_feature_names(self): - """Return the names of the features that the stratigraphic column makes.""" - if self.stratigraphic_column is None: + """Return the names of the features that the stratigraphic column makes. + + The list is empty with "constraints": the build does not use the column. + """ + if self.stratigraphic_column is None or not self.uses_map_data: return [] return [group.name for group in self.stratigraphic_column.get_groups()] @@ -1649,11 +1681,16 @@ def update_model( if notify_observers: self._emit('model_update_started') + map_data = self.uses_map_data group_count = ( - len(self.stratigraphic_column.get_groups()) if self.stratigraphic_column else 0 + len(self.stratigraphic_column.get_groups()) + if map_data and self.stratigraphic_column + else 0 ) self._progress_callback = progress_callback - displacement_fault_count = len(set(self.faults) - set(self.fault_boundaries.values())) + displacement_fault_count = ( + len(set(self.faults) - set(self.fault_boundaries.values())) if map_data else 0 + ) self._progress_total = ( displacement_fault_count + len(self.parametric_faults) @@ -1665,17 +1702,19 @@ def update_model( if dbg is not None: try: dbg.log( - f"Initialize Model: building {len(self.faults)} fault(s) and " + f"Initialize Model: building {displacement_fault_count} fault(s) and " f"{group_count} stratigraphic group(s)", log_level=0, ) except Exception: pass - self._built_fault_data = self._copy_fault_data() + # With "constraints", the fault traces are not used + self._built_fault_data = self._copy_fault_data() if map_data else {} try: - # Update the model with stratigraphy - self.update_fault_features() - self.update_foliation_features() + if map_data: + # Update the model with stratigraphy + self.update_fault_features() + self.update_foliation_features() # after the stratigraphy, so each gets the same unconformity # regions as when the user added it self._build_manual_foliations() @@ -1755,7 +1794,8 @@ def refresh(name, data): builder.set_not_up_to_date(self) updated.append(name) - if self.stratigraphic_column is not None: + map_data = self.uses_map_data + if map_data and self.stratigraphic_column is not None: isovalues = self.stratigraphic_column.get_isovalues() for group in self.stratigraphic_column.get_groups(): if qgisAttributeIsNone(group) is None: @@ -1778,7 +1818,8 @@ def refresh(name, data): continue refresh(name, data) - for name in set(self.faults) | set(self._built_fault_data): + # the fault traces are not used with "constraints" + for name in (set(self.faults) | set(self._built_fault_data)) if map_data else (): new_data = self.faults.get(name, {}).get('data') built_data = self._built_fault_data.get(name) if new_data is None and built_data is None: diff --git a/tests/qgis/test_workflow_mode_model.py b/tests/qgis/test_workflow_mode_model.py new file mode 100644 index 0000000..05c8a35 --- /dev/null +++ b/tests/qgis/test_workflow_mode_model.py @@ -0,0 +1,163 @@ +"""Pytest tests for the two workflows of the model manager. + +With "constraints", the model has only the features that the user added. The +column and the fault traces keep their data, but the build does not use it. +With "map", the model has the generated features and the features that the +user added. +""" + +import numpy as np +import pandas as pd +import pytest +from LoopStructural import StratigraphicColumn +from LoopStructural.datatypes import BoundingBox + +from loopstructural.main.model_manager import GeologicalModelManager +from loopstructural.main.workflow_mode import WORKFLOW_MODE_CONSTRAINTS, WORKFLOW_MODE_MAP +from loopstructural.toolbelt.preferences import PlgSettingsStructure + +POINTS = [(x, y) for x in (20.0, 50.0, 80.0) for y in (20.0, 50.0, 80.0)] + + +def _value_layer(): + import geopandas as gpd + from shapely.geometry import Point + + points = [Point(x, y, 0.0) for x, y in POINTS] + gdf = gpd.GeoDataFrame({'value': [p.x / 10.0 for p in points]}, geometry=points) + return {'layer_name': 'values', 'type': 'Value', 'value_field': 'value', 'df': gdf} + + +def _contact(z): + return pd.DataFrame({'X': [20.0, 50.0, 80.0], 'Y': [20.0, 50.0, 80.0], 'Z': [z, z, z]}) + + +def _orientations(): + return pd.DataFrame({'X': [50.0], 'Y': [50.0], 'Z': [0.0], 'dip': [0.0], 'strike': [0.0]}) + + +class _DebugManager: + def log(self, *args, **kwargs): + pass + + +def _one_group_column(): + column = StratigraphicColumn() + column.clear(basement=False) + column.add_unit(name='lower', thickness=20.0, where='top') + column.add_unit(name='upper', thickness=20.0, where='top') + return column + + +@pytest.fixture +def manager(monkeypatch): + """A manager with a column, contacts and one fault trace, and one manual foliation.""" + monkeypatch.setattr(PlgSettingsStructure, 'interpolator_nelements', 200) + manager = GeologicalModelManager(debug_manager=_DebugManager()) + manager.update_bounding_box(BoundingBox(origin=[0, 0, -50], maximum=[100, 100, 50])) + manager.stratigraphic_column = _one_group_column() + manager.stratigraphy['lower']['contact'] = _contact(-10.0) + manager.stratigraphy['upper']['contact'] = _contact(10.0) + manager.stratigraphy['upper']['orientations'] = _orientations() + # the fault build itself is not under test here + monkeypatch.setattr(manager.model, 'create_and_add_fault', lambda *a, **k: None) + manager.faults['f1']['data'] = pd.DataFrame( + {'X': [10.0, 90.0], 'Y': [50.0, 50.0], 'Z': [0.0, 0.0]} + ) + manager.add_foliation('s1', {'values': _value_layer()}, use_z_coordinate=True) + return manager + + +def _feature_names(manager): + return [f.name for f in manager.features() if not f.name.startswith('__')] + + +class TestBuild: + def test_constraints_build_only_the_manual_features(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + manager.update_model(notify_observers=False) + assert _feature_names(manager) == ['s1'] + + def test_map_builds_the_generated_and_the_manual_features(self, manager): + manager.update_model(notify_observers=False) + group_name = manager.stratigraphic_column.get_groups()[0].name + assert group_name in _feature_names(manager) + assert 's1' in _feature_names(manager) + + def test_the_data_stays_when_the_mode_changes(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + manager.update_model(notify_observers=False) + assert 'f1' in manager.faults + assert not manager.stratigraphy['lower']['contact'].empty + manager.set_workflow_mode(WORKFLOW_MODE_MAP) + manager.update_model(notify_observers=False) + group_name = manager.stratigraphic_column.get_groups()[0].name + assert group_name in _feature_names(manager) + + def test_generated_feature_names_are_empty_with_constraints(self, manager): + assert manager.generated_feature_names() + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + assert manager.generated_feature_names() == [] + + def test_an_unknown_mode_is_an_error(self, manager): + with pytest.raises(ValueError): + manager.set_workflow_mode('other') + + +class TestState: + def test_a_column_change_does_not_make_a_constraints_model_stale(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + manager.update_model(notify_observers=False) + manager.update_all_features(notify_observers=False) + assert manager.model_state == 'solved' + manager.stratigraphic_column.add_unconformity(name='new', where='top') + assert manager.model_state == 'solved' + + def test_a_column_change_makes_a_map_model_stale(self, manager): + manager.set_stratigraphic_column(manager.stratigraphic_column) + manager.update_model(notify_observers=False) + manager.stratigraphic_column.add_unconformity(name='new', where='top') + assert manager.model_state == 'stale' + + def test_a_change_of_the_mode_makes_a_built_model_stale(self, manager): + manager.update_model(notify_observers=False) + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + assert manager.model_state == 'stale' + + def test_a_change_of_the_mode_of_an_empty_model_is_not_stale(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + assert manager.model_state == 'empty' + + def test_the_same_mode_does_not_make_the_model_stale(self, manager): + manager.update_model(notify_observers=False) + manager.set_workflow_mode(WORKFLOW_MODE_MAP) + assert manager.model_state != 'stale' + + +class TestRefresh: + def test_edited_contacts_do_not_change_a_constraints_model(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + manager.update_model(notify_observers=False) + manager.stratigraphy['lower']['contact'] = _contact(-20.0) + manager.faults['f1']['data'] = pd.DataFrame( + {'X': [10.0, 90.0], 'Y': [40.0, 60.0], 'Z': [0.0, 0.0]} + ) + + result = manager.refresh_feature_data() + + assert result == {'updated': [], 'needs_initialize': []} + assert manager.model_state != 'stale' + + def test_the_manual_foliation_is_still_refreshed(self, manager): + manager.set_workflow_mode(WORKFLOW_MODE_CONSTRAINTS) + manager.update_model(notify_observers=False) + layer = _value_layer() + layer['df']['value'] = layer['df']['value'] * 2.0 + manager.manual_foliations['s1']['data']['values'] = layer + + result = manager.refresh_feature_data() + + assert result['updated'] == ['s1'] + manager.update_all_features(notify_observers=False) + value = manager.model['s1'].evaluate_value(np.array([[50.0, 50.0, 0.0]])) + assert np.isfinite(value).all() diff --git a/tests/unit/test_build_plan.py b/tests/unit/test_build_plan.py index 72d6a48..0743959 100644 --- a/tests/unit/test_build_plan.py +++ b/tests/unit/test_build_plan.py @@ -55,6 +55,34 @@ def test_a_blocked_build_is_disabled_with_the_reason(self): assert action.tooltip == "Set the bounding box." +class TestPrimaryActionWorkflow: + def test_constraints_name_the_workflow(self): + action = build_plan.choose_primary_action( + 'empty', workflow_mode=WORKFLOW_MODE_CONSTRAINTS + ) + assert action.text == "Build model from constraints" + assert 'constraints' in action.tooltip + + def test_map_keeps_the_default_text(self): + action = build_plan.choose_primary_action('empty', workflow_mode=WORKFLOW_MODE_MAP) + assert action.text == "Build model" + + def test_constraints_ignore_out_of_date_derived_data(self): + action = build_plan.choose_primary_action( + 'solved', + derived_out_of_date=[derived_data.BASAL_CONTACTS], + workflow_mode=WORKFLOW_MODE_CONSTRAINTS, + ) + assert action.action == build_plan.ACTION_SOLVE + + def test_constraints_name_the_workflow_when_blocked(self): + action = build_plan.choose_primary_action( + 'empty', blocked_reason="Set the bounding box.", workflow_mode=WORKFLOW_MODE_CONSTRAINTS + ) + assert not action.enabled + assert action.text == "Build model from constraints" + + class TestProblems: def test_problems_of_all_steps_in_the_order_of_the_steps(self): problems = build_plan.collect_problems( diff --git a/tests/unit/test_step_checks.py b/tests/unit/test_step_checks.py index 38da9ed..f771eb6 100644 --- a/tests/unit/test_step_checks.py +++ b/tests/unit/test_step_checks.py @@ -90,21 +90,37 @@ def test_a_new_project_is_not_started(self, dm): assert check.status == Status.NOT_STARTED assert 'Set the bounding box.' in check.todo - def test_all_inputs_set_is_done(self, dm): + def test_the_area_set_is_done(self, dm): dm.bounding_box_set = True - dm.layer_roles.values = {layer_roles.GEOLOGY: object(), layer_roles.STRUCTURE: object()} assert checks.check_data(dm).status == Status.DONE + def test_the_map_layers_are_not_a_todo_of_step_1(self, dm): + dm.bounding_box_set = True + assert checks.check_data(dm).todo == () + def test_a_geographic_crs_is_a_problem(self, dm): dm.crs_valid = False assert checks.check_data(dm).status == Status.PROBLEM class TestStratigraphyStep: + @pytest.fixture(autouse=True) + def map_layers(self, dm, request): + if request.node.name not in ('test_an_empty_column_is_not_started', 'test_missing_map_layers_are_a_todo'): + dm.layer_roles.values = { + layer_roles.GEOLOGY: object(), + layer_roles.STRUCTURE: object(), + } + def test_an_empty_column_is_not_started(self, dm): check = checks.check_stratigraphy(dm) assert check.status == Status.NOT_STARTED - assert check.todo == ('Add units to the stratigraphic column.',) + assert 'Add units to the stratigraphic column.' in check.todo + + def test_missing_map_layers_are_a_todo(self, dm): + todo = checks.check_stratigraphy(dm).todo + assert 'Select the geology layer.' in todo + assert 'Select the structure layer.' in todo def test_units_without_contacts_are_a_problem(self, dm): dm.units = ['a', 'b', 'c'] From f3cc922eab9085e4b51450ef4599e62e68630cd0 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 10:57:41 -0300 Subject: [PATCH 2/2] docs: add 6.5 to the usability plan, a model with only faults --- docs/development/usability-plan.md | 44 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index 86db1da..45ead63 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -346,7 +346,7 @@ cross-section to files without the 3D view. ### Phase 6: Follow-up changes -Four changes that come from use of phases 3 to 5. Do them in this order. Each +Five changes that come from use of phases 3 to 5. Do them in this order. Each one is a separate pull request. #### 6.1 Two separate workflows @@ -542,10 +542,50 @@ Tests: unit tests for the function: more data gives more or equal elements; the limits; no data; parallel and spread orientations; orientation signs do not matter. +#### 6.5 Build a model with only faults + +Problem: the "map" workflow needs a stratigraphic column and basal contacts. A +user who has only fault traces (for example, to model the faults alone) cannot +build a model. The build asks for contacts, and the contacts extraction stops +with "No basal contacts were found". + +Rules: + +- In the "map" workflow, the stratigraphic column and the basal contacts are + optional. A model with fault traces only builds the faults. +- With no units in the column, the build does not calculate the basal contacts + and the thicknesses, and it makes no stratigraphic feature. +- With units in the column but no contacts for a unit, the build gives the + current message. Only an empty column skips the contacts. + +Tasks: + +- [ ] `names_to_refresh` returns no result that comes from the column when the + column has no units. `DerivedRefresh` does not raise when there are no + units. +- [ ] `update_model` builds the faults when the column has no groups. + `update_foliation_features` does nothing for an empty column. Check that + `model.stratigraphic_column` is safe to leave unset. +- [ ] `check_stratigraphy` does not ask for the geology layer, the structure + layer or the contacts when the column is empty. The text says that the + step is optional if the user models only faults. +- [ ] `model_state`, `valid` and the primary action accept a model that has + faults and no groups. +- [ ] Step 5 (view and export) works with fault features only. The block model + and the stratigraphic surfaces are not offered when there are no units. + +Acceptance: a project has a fault trace layer and an empty column. The user +sets the bounding box and the fault layer, and builds the model. The model has +the fault features, and the user can view and export the fault surfaces. + +Tests: unit tests for `names_to_refresh` and for the checks with an empty +column. A QGIS test of `update_model` with faults and no column. + Order and links between the parts: 6.1 first, because it defines what the model reads in each workflow. 6.2 depends on the same checks, so do it next. 6.3 comes after 6.2, because it must lay out the final content of the pages. 6.4 -does not depend on the others. +does not depend on the others. 6.5 comes after 6.2, because it changes the +same checks. ## Risks