Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions docs/development/usability-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions loopstructural/gui/modelling/steps/build_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions loopstructural/gui/modelling/steps/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
48 changes: 41 additions & 7 deletions loopstructural/main/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading