Skip to content
Merged
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
4 changes: 3 additions & 1 deletion loopstructural/gui/map2loop_tools/sorter_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,9 @@ def get_parameters(self):
Dictionary of current widget parameters.
"""
algorithm_index = self.sortingAlgorithmComboBox.currentIndex()
is_observation_projections = self.sorting_algorithms[algorithm_index] == "Observation projections"
is_observation_projections = (
self.sorting_algorithms[algorithm_index] == "Observation projections"
)

params = {
'sorting_algorithm': algorithm_index,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -742,4 +742,3 @@ def schedule_rebuild(self):
obs()
except Exception:
pass

Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _build_status_icon(color: str, *, filled: bool, mark: str = None) -> QIcon:
'empty': "Model status: not initialized",
'initialized': "Model status: initialized (not solved)",
'solved': "Model status: solved",
'stale': "Model status: fault topology changed — re-run Initialize Model",
'stale': "Model status: faults or input data changed — re-run Initialize Model",
}

# Solve Model only rebuilds interpolators for features that already exist; it
Expand Down Expand Up @@ -119,7 +119,7 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None):

# Splitter for collapsible layout. Given all the stretch so the
# button/status row above it never competes for space.
splitter = QSplitter(self)
splitter = self._splitter = QSplitter(self)
mainLayout.addWidget(splitter, 1)

# Feature list panel
Expand Down Expand Up @@ -170,6 +170,30 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None):
buttonRowWidget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
mainLayout.insertWidget(0, buttonRowWidget, 0)

# Shown when an input layer changed after the model data was read
# from it. "Update Model Data" puts the new data into the existing
# features, so the changes made to them are kept (Initialize Model
# builds every feature again).
self.layerChangedLabel = QLabel()
self.layerChangedLabel.setWordWrap(True)
self.updateModelDataButton = QPushButton("Update Model Data")
self.updateModelDataButton.setToolTip(
"Read the changed layers again and put the new data into the current "
"features. Solve Model then uses the new data."
)
self.updateModelDataButton.clicked.connect(self.update_model_data)
layerChangedRow = QHBoxLayout()
layerChangedRow.setContentsMargins(0, 0, 0, 0)
layerChangedRow.addWidget(self.layerChangedLabel, 1)
layerChangedRow.addWidget(self.updateModelDataButton)
self.layerChangedWidget = QWidget()
self.layerChangedWidget.setLayout(layerChangedRow)
self.layerChangedWidget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
mainLayout.insertWidget(1, self.layerChangedWidget, 0)
self.layerChangedWidget.hide()
if self.data_manager is not None:
self.data_manager.add_layer_data_changed_callback(self._refresh_changed_layers)

# Action buttons

self.initializeModelButton.clicked.connect(self.initialize_model)
Expand Down Expand Up @@ -257,6 +281,11 @@ def initialize_model(self):
if not self._confirm_bounding_box_contains_data():
return

if self.data_manager is not None:
# build from the current layer data, not the data read before
# the layers changed
self.data_manager.reload_changed_layers()

self._run_model_task(
lambda progress_callback: self.model_manager.update_model(
notify_observers=False, progress_callback=progress_callback
Expand All @@ -265,6 +294,38 @@ def initialize_model(self):
initial_label="Updating geological model...",
)

def update_model_data(self):
"""Put the data of the changed input layers into the current
features, without Initialize Model."""
if self.data_manager is None or self.model_manager is None:
return
try:
result = self.data_manager.refresh_model_data()
except Exception as e:
QMessageBox.critical(self, "Update model data failed", str(e))
return
self._refresh_model_status()
if result['needs_initialize']:
names = "\n".join(f" - {name}" for name in result['needs_initialize'])
QMessageBox.information(
self,
"Initialize Model needed",
"The new data for these features cannot be put into the current "
f"model:\n{names}\n\n"
"Run Initialize Model to use it. Initialize Model builds all "
"features again.",
)

def _refresh_changed_layers(self):
names = self.data_manager.get_changed_layers() if self.data_manager else []
if not names:
self.layerChangedWidget.hide()
return
self.layerChangedLabel.setText(
"Input layers changed after the model data was read: " + ", ".join(names)
)
self.layerChangedWidget.show()

def solve_model(self):
# Build/interpolate every feature already added to the model. Only
# meaningful once Initialize Model has created some features, and not
Expand Down Expand Up @@ -513,7 +574,7 @@ def on_feature_selected(self, item):
self.featureDetailsPanel = QWidget() # Default empty panel

# Dynamically replace the featureDetailsPanel widget
splitter = self.layout().itemAt(1).widget()
splitter = self._splitter
splitter.widget(1).deleteLater() # Remove the existing widget
splitter.addWidget(self.featureDetailsPanel) # Add the new widget

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,7 @@ def _setup_form_line_fields(self, layout):
dip_layout = QFormLayout(self.dip_group)

self.constrain_dip_checkbox = QCheckBox("Also constrain dip (weak)")
self.constrain_dip_checkbox.setChecked(
self.existing_data.get('form_line_dip') is not None
)
self.constrain_dip_checkbox.setChecked(self.existing_data.get('form_line_dip') is not None)
dip_layout.addRow(self.constrain_dip_checkbox)

self.dip_spin = QDoubleSpinBox()
Expand Down
174 changes: 174 additions & 0 deletions loopstructural/main/data_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from collections import defaultdict
from functools import partial
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -119,6 +120,13 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None):
self._model_crs = None
self._use_project_crs = True
self.model_crs_callback = None
# layer id -> (layer, slots) for each input layer whose data this
# manager listens to; see `refresh_layer_watchers`.
self._watched_layers = {}
# ids of the watched layers whose data changed after the model
# data was last read from them
self._changed_layer_ids = set()
self._layer_data_changed_callbacks = []

def onSaveProject(self):
"""Save project data."""
Expand Down Expand Up @@ -433,6 +441,7 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordina
self.update_stratigraphy()
if self.basal_contacts_callback:
self.basal_contacts_callback(**self._basal_contacts)
self.refresh_layer_watchers()

def calculate_unique_basal_units(self):
if (
Expand Down Expand Up @@ -806,6 +815,156 @@ def get_input_layers(self):
layers[role] = layer
return layers

def _manual_foliation_layer_rows(self):
"""Yield `(layer, layer_data)` for each input layer of the foliations
the user added (`GeologicalModelManager.manual_foliations`).

A foliation loaded from a state file has no QGIS layer object, so
its layer is found again by name.
"""
if self._model_manager is None:
return
for spec in self._model_manager.manual_foliations.values():
for key, layer_data in spec['data'].items():
if layer_data.get('processed'):
continue
layer = layer_data.get('layer')
if layer is None:
layer = self.find_layer_by_name(layer_data.get('layer_name', key))
if layer is not None:
yield layer, layer_data

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'])
layers.extend(layer for layer, _ in self._manual_foliation_layer_rows())
watched = {}
for layer in layers:
try:
if isinstance(layer, QgsVectorLayer) and layer.isValid():
watched[layer.id()] = layer
except RuntimeError:
# underlying C++ layer was deleted
continue
return watched

def refresh_layer_watchers(self):
"""Listen for data changes on each layer the model reads data from,
and stop listening to the layers it no longer uses.

An edit in the edit buffer, a commit, a new data source or a reload
flags the layer as changed, see `get_changed_layers`. A change
written straight to the data provider (not through the layer) emits
none of these signals.
"""
wanted = self._layers_to_watch()
for layer_id in list(self._watched_layers):
if layer_id not in wanted:
self._unwatch_layer(layer_id)
for layer_id, layer in wanted.items():
if layer_id in self._watched_layers:
continue
on_changed = partial(self._on_layer_data_changed, layer_id)
on_deleted = partial(self._unwatch_layer, layer_id)
for signal in self._layer_change_signals(layer):
signal.connect(on_changed)
layer.willBeDeleted.connect(on_deleted)
self._watched_layers[layer_id] = (layer, on_changed, on_deleted)

@staticmethod
def _layer_change_signals(layer):
return (
layer.layerModified,
layer.afterCommitChanges,
layer.dataSourceChanged,
layer.dataChanged,
)

def _unwatch_layer(self, layer_id):
layer, on_changed, on_deleted = self._watched_layers.pop(layer_id)
if layer_id in self._changed_layer_ids:
self._changed_layer_ids.discard(layer_id)
self._notify_layer_data_changed()
try:
for signal in self._layer_change_signals(layer):
signal.disconnect(on_changed)
layer.willBeDeleted.disconnect(on_deleted)
except (RuntimeError, TypeError):
# the layer was deleted, or the slot was not connected
pass

def _on_layer_data_changed(self, layer_id):
self._changed_layer_ids.add(layer_id)
self._notify_layer_data_changed()

def _notify_layer_data_changed(self):
for callback in self._layer_data_changed_callbacks:
try:
callback()
except Exception as e:
self.logger(message=f"Error in layer data changed callback: {e}", log_level=2)

def add_layer_data_changed_callback(self, callback):
"""Call `callback()` when a watched layer changes, and when the
changed layers are read again."""
self._layer_data_changed_callbacks.append(callback)

def get_changed_layers(self):
"""Return the names of the input layers whose data changed after the
model data was last read from them."""
return sorted(self._watched_layers[i][0].name() for i in self._changed_layer_ids)

def reload_changed_layers(self):
"""Read the model data again from the input layers that changed.

This only updates the data that the model manager holds (the
stratigraphy, the faults and the data of the foliations the user
added). Initialize Model uses this data, and
`GeologicalModelManager.refresh_feature_data` puts it into the
existing features.
"""
changed = set(self._changed_layer_ids)
if not changed:
return

def is_changed(config):
layer = config.get('layer') if config else None
try:
return layer is not None and layer.id() in changed
except RuntimeError:
return False

if is_changed(self._basal_contacts):
self.calculate_unique_basal_units()
if is_changed(self._basal_contacts) or is_changed(self._structural_orientations):
self.update_stratigraphy()
if is_changed(self._fault_traces):
self.update_faults()
model_crs = self.get_model_crs()
for layer, layer_data in self._manual_foliation_layer_rows():
if layer.id() in changed:
layer_data['df'] = qgsLayerToGeoDataFrame(layer, target_crs=model_crs)
self._changed_layer_ids.clear()
self._notify_layer_data_changed()

def refresh_model_data(self):
"""Read the changed input layers again and put their data into the
features already in the model, without Initialize Model.

Returns
-------
dict
See `GeologicalModelManager.refresh_feature_data`.
"""
if self._model_manager is None:
raise RuntimeError("Model manager is not set.")
self.reload_changed_layers()
return self._model_manager.refresh_feature_data()

def get_layers_outside_bounding_box(self):
"""Check which input layers do not overlap the bounding box in XY.

Expand Down Expand Up @@ -897,6 +1056,7 @@ def set_fault_trace_layer(
self.update_faults()
if self.fault_traces_callback:
self.fault_traces_callback(**self._fault_traces)
self.refresh_layer_watchers()

def get_fault_traces(self) -> Optional[FaultTracesConfig]:
"""Get the fault traces."""
Expand All @@ -922,6 +1082,7 @@ def set_structural_orientations(
if self.structural_orientations_callback:
self.structural_orientations_callback(**self._structural_orientations)
self.update_stratigraphy()
self.refresh_layer_watchers()

def get_structural_orientations(self) -> Optional[StructuralOrientationsConfig]:
"""Get the structural orientations."""
Expand Down Expand Up @@ -1140,6 +1301,9 @@ def reset(self):
self._model_manager.update_bounding_box(self._bounding_box)
self._model_manager.set_dem_function(self.dem_function)

self._changed_layer_ids.clear()
self.refresh_layer_watchers()

self.logger(message="Application state reset.", log_level=3)

def save_state(self, filepath):
Expand All @@ -1163,6 +1327,7 @@ def save_state(self, filepath):
model_path = path.parent / f"{path.name}.model"
self._model_manager.save_model(str(model_path))
state['model_file'] = model_path.name
state['manual_foliations'] = self._model_manager.manual_foliations_to_dict()

with open(path, 'w') as f:
json.dump(state, f, indent=2)
Expand Down Expand Up @@ -1191,6 +1356,13 @@ def load_state(self, filepath):
if model_filename and self._model_manager is not None:
model_path = path.parent / model_filename
self._model_manager.load_model(str(model_path))
if self._model_manager is not None:
# the pickled model already has these features; this lets
# Initialize Model build them again
self._model_manager.manual_foliations_from_dict(state.get('manual_foliations', {}))
# the data was just read from the layers
self._changed_layer_ids.clear()
self.refresh_layer_watchers()

self.logger(message=f"Loaded application state from '{path}'.", log_level=3)

Expand Down Expand Up @@ -1466,6 +1638,7 @@ def update_feature_data(self, feature_name: str, feature_data: dict):
raise ValueError("feature_data must be a dictionary.")
self.feature_data[feature_name][feature_data['layer_name']] = feature_data
self.logger(message=f"Updated feature data for '{feature_name}'.")
self.refresh_layer_watchers()

def set_widget_settings(self, widget_name: str, settings: dict):
"""Store widget settings for persistence."""
Expand Down Expand Up @@ -1505,5 +1678,6 @@ def add_foliation_to_model(
restrict_to_stratigraphic_domain=restrict_to_stratigraphic_domain,
)
self.logger(message=f"Added foliation '{foliation_name}' to the model.")
self.refresh_layer_watchers()
else:
raise RuntimeError("Model manager is not set.")
Loading
Loading