Skip to content

Commit fc572fe

Browse files
authored
Feat/save manual foliations (#123)
* feat: save manual foliations in the state file The pickled model keeps the solved manual foliations, but GeologicalModelManager.manual_foliations was not saved. After a load, Initialize Model cleared the model features and could not build these foliations again. save_state now writes the foliation specs to the JSON state file, and load_state reads them again. * feat: update the model data when an input layer changes The data manager now watches the layers that the model reads data from (basal contacts, fault traces, structural orientations and the layers of user-added foliations). When one of them changes, the Geological Model tab shows the changed layers and an "Update Model Data" button. The button reads the changed layers again and puts the new data into the features that are already in the model, so the changes made to those features stay (Initialize Model builds every feature again). A changed fault trace, or a group that gets or loses all of its data, cannot go into the current model; the model status then asks for Initialize Model. Initialize Model now also reads the changed layers again before it builds the model. * style: apply black formatting
1 parent c5a63fa commit fc572fe

10 files changed

Lines changed: 790 additions & 51 deletions

File tree

‎loopstructural/gui/map2loop_tools/sorter_widget.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,9 @@ def get_parameters(self):
500500
Dictionary of current widget parameters.
501501
"""
502502
algorithm_index = self.sortingAlgorithmComboBox.currentIndex()
503-
is_observation_projections = self.sorting_algorithms[algorithm_index] == "Observation projections"
503+
is_observation_projections = (
504+
self.sorting_algorithms[algorithm_index] == "Observation projections"
505+
)
504506

505507
params = {
506508
'sorting_algorithm': algorithm_index,

‎loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,4 +742,3 @@ def schedule_rebuild(self):
742742
obs()
743743
except Exception:
744744
pass
745-

‎loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _build_status_icon(color: str, *, filled: bool, mark: str = None) -> QIcon:
6565
'empty': "Model status: not initialized",
6666
'initialized': "Model status: initialized (not solved)",
6767
'solved': "Model status: solved",
68-
'stale': "Model status: fault topology changed — re-run Initialize Model",
68+
'stale': "Model status: faults or input data changed — re-run Initialize Model",
6969
}
7070

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

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

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

173+
# Shown when an input layer changed after the model data was read
174+
# from it. "Update Model Data" puts the new data into the existing
175+
# features, so the changes made to them are kept (Initialize Model
176+
# builds every feature again).
177+
self.layerChangedLabel = QLabel()
178+
self.layerChangedLabel.setWordWrap(True)
179+
self.updateModelDataButton = QPushButton("Update Model Data")
180+
self.updateModelDataButton.setToolTip(
181+
"Read the changed layers again and put the new data into the current "
182+
"features. Solve Model then uses the new data."
183+
)
184+
self.updateModelDataButton.clicked.connect(self.update_model_data)
185+
layerChangedRow = QHBoxLayout()
186+
layerChangedRow.setContentsMargins(0, 0, 0, 0)
187+
layerChangedRow.addWidget(self.layerChangedLabel, 1)
188+
layerChangedRow.addWidget(self.updateModelDataButton)
189+
self.layerChangedWidget = QWidget()
190+
self.layerChangedWidget.setLayout(layerChangedRow)
191+
self.layerChangedWidget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
192+
mainLayout.insertWidget(1, self.layerChangedWidget, 0)
193+
self.layerChangedWidget.hide()
194+
if self.data_manager is not None:
195+
self.data_manager.add_layer_data_changed_callback(self._refresh_changed_layers)
196+
173197
# Action buttons
174198

175199
self.initializeModelButton.clicked.connect(self.initialize_model)
@@ -257,6 +281,11 @@ def initialize_model(self):
257281
if not self._confirm_bounding_box_contains_data():
258282
return
259283

284+
if self.data_manager is not None:
285+
# build from the current layer data, not the data read before
286+
# the layers changed
287+
self.data_manager.reload_changed_layers()
288+
260289
self._run_model_task(
261290
lambda progress_callback: self.model_manager.update_model(
262291
notify_observers=False, progress_callback=progress_callback
@@ -265,6 +294,38 @@ def initialize_model(self):
265294
initial_label="Updating geological model...",
266295
)
267296

297+
def update_model_data(self):
298+
"""Put the data of the changed input layers into the current
299+
features, without Initialize Model."""
300+
if self.data_manager is None or self.model_manager is None:
301+
return
302+
try:
303+
result = self.data_manager.refresh_model_data()
304+
except Exception as e:
305+
QMessageBox.critical(self, "Update model data failed", str(e))
306+
return
307+
self._refresh_model_status()
308+
if result['needs_initialize']:
309+
names = "\n".join(f" - {name}" for name in result['needs_initialize'])
310+
QMessageBox.information(
311+
self,
312+
"Initialize Model needed",
313+
"The new data for these features cannot be put into the current "
314+
f"model:\n{names}\n\n"
315+
"Run Initialize Model to use it. Initialize Model builds all "
316+
"features again.",
317+
)
318+
319+
def _refresh_changed_layers(self):
320+
names = self.data_manager.get_changed_layers() if self.data_manager else []
321+
if not names:
322+
self.layerChangedWidget.hide()
323+
return
324+
self.layerChangedLabel.setText(
325+
"Input layers changed after the model data was read: " + ", ".join(names)
326+
)
327+
self.layerChangedWidget.show()
328+
268329
def solve_model(self):
269330
# Build/interpolate every feature already added to the model. Only
270331
# meaningful once Initialize Model has created some features, and not
@@ -513,7 +574,7 @@ def on_feature_selected(self, item):
513574
self.featureDetailsPanel = QWidget() # Default empty panel
514575

515576
# Dynamically replace the featureDetailsPanel widget
516-
splitter = self.layout().itemAt(1).widget()
577+
splitter = self._splitter
517578
splitter.widget(1).deleteLater() # Remove the existing widget
518579
splitter.addWidget(self.featureDetailsPanel) # Add the new widget
519580

‎loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,7 @@ def _setup_form_line_fields(self, layout):
543543
dip_layout = QFormLayout(self.dip_group)
544544

545545
self.constrain_dip_checkbox = QCheckBox("Also constrain dip (weak)")
546-
self.constrain_dip_checkbox.setChecked(
547-
self.existing_data.get('form_line_dip') is not None
548-
)
546+
self.constrain_dip_checkbox.setChecked(self.existing_data.get('form_line_dip') is not None)
549547
dip_layout.addRow(self.constrain_dip_checkbox)
550548

551549
self.dip_spin = QDoubleSpinBox()

‎loopstructural/main/data_manager.py‎

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from collections import defaultdict
3+
from functools import partial
34
from pathlib import Path
45
from typing import Optional
56

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

123131
def onSaveProject(self):
124132
"""Save project data."""
@@ -433,6 +441,7 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordina
433441
self.update_stratigraphy()
434442
if self.basal_contacts_callback:
435443
self.basal_contacts_callback(**self._basal_contacts)
444+
self.refresh_layer_watchers()
436445

437446
def calculate_unique_basal_units(self):
438447
if (
@@ -806,6 +815,156 @@ def get_input_layers(self):
806815
layers[role] = layer
807816
return layers
808817

818+
def _manual_foliation_layer_rows(self):
819+
"""Yield `(layer, layer_data)` for each input layer of the foliations
820+
the user added (`GeologicalModelManager.manual_foliations`).
821+
822+
A foliation loaded from a state file has no QGIS layer object, so
823+
its layer is found again by name.
824+
"""
825+
if self._model_manager is None:
826+
return
827+
for spec in self._model_manager.manual_foliations.values():
828+
for key, layer_data in spec['data'].items():
829+
if layer_data.get('processed'):
830+
continue
831+
layer = layer_data.get('layer')
832+
if layer is None:
833+
layer = self.find_layer_by_name(layer_data.get('layer_name', key))
834+
if layer is not None:
835+
yield layer, layer_data
836+
837+
def _layers_to_watch(self):
838+
"""Return {layer id: layer} for every layer the model reads data from."""
839+
layers = list(self.get_input_layers().values())
840+
for entries in self.feature_data.values():
841+
for entry in entries.values():
842+
if not entry.get('processed') and entry.get('layer') is not None:
843+
layers.append(entry['layer'])
844+
layers.extend(layer for layer, _ in self._manual_foliation_layer_rows())
845+
watched = {}
846+
for layer in layers:
847+
try:
848+
if isinstance(layer, QgsVectorLayer) and layer.isValid():
849+
watched[layer.id()] = layer
850+
except RuntimeError:
851+
# underlying C++ layer was deleted
852+
continue
853+
return watched
854+
855+
def refresh_layer_watchers(self):
856+
"""Listen for data changes on each layer the model reads data from,
857+
and stop listening to the layers it no longer uses.
858+
859+
An edit in the edit buffer, a commit, a new data source or a reload
860+
flags the layer as changed, see `get_changed_layers`. A change
861+
written straight to the data provider (not through the layer) emits
862+
none of these signals.
863+
"""
864+
wanted = self._layers_to_watch()
865+
for layer_id in list(self._watched_layers):
866+
if layer_id not in wanted:
867+
self._unwatch_layer(layer_id)
868+
for layer_id, layer in wanted.items():
869+
if layer_id in self._watched_layers:
870+
continue
871+
on_changed = partial(self._on_layer_data_changed, layer_id)
872+
on_deleted = partial(self._unwatch_layer, layer_id)
873+
for signal in self._layer_change_signals(layer):
874+
signal.connect(on_changed)
875+
layer.willBeDeleted.connect(on_deleted)
876+
self._watched_layers[layer_id] = (layer, on_changed, on_deleted)
877+
878+
@staticmethod
879+
def _layer_change_signals(layer):
880+
return (
881+
layer.layerModified,
882+
layer.afterCommitChanges,
883+
layer.dataSourceChanged,
884+
layer.dataChanged,
885+
)
886+
887+
def _unwatch_layer(self, layer_id):
888+
layer, on_changed, on_deleted = self._watched_layers.pop(layer_id)
889+
if layer_id in self._changed_layer_ids:
890+
self._changed_layer_ids.discard(layer_id)
891+
self._notify_layer_data_changed()
892+
try:
893+
for signal in self._layer_change_signals(layer):
894+
signal.disconnect(on_changed)
895+
layer.willBeDeleted.disconnect(on_deleted)
896+
except (RuntimeError, TypeError):
897+
# the layer was deleted, or the slot was not connected
898+
pass
899+
900+
def _on_layer_data_changed(self, layer_id):
901+
self._changed_layer_ids.add(layer_id)
902+
self._notify_layer_data_changed()
903+
904+
def _notify_layer_data_changed(self):
905+
for callback in self._layer_data_changed_callbacks:
906+
try:
907+
callback()
908+
except Exception as e:
909+
self.logger(message=f"Error in layer data changed callback: {e}", log_level=2)
910+
911+
def add_layer_data_changed_callback(self, callback):
912+
"""Call `callback()` when a watched layer changes, and when the
913+
changed layers are read again."""
914+
self._layer_data_changed_callbacks.append(callback)
915+
916+
def get_changed_layers(self):
917+
"""Return the names of the input layers whose data changed after the
918+
model data was last read from them."""
919+
return sorted(self._watched_layers[i][0].name() for i in self._changed_layer_ids)
920+
921+
def reload_changed_layers(self):
922+
"""Read the model data again from the input layers that changed.
923+
924+
This only updates the data that the model manager holds (the
925+
stratigraphy, the faults and the data of the foliations the user
926+
added). Initialize Model uses this data, and
927+
`GeologicalModelManager.refresh_feature_data` puts it into the
928+
existing features.
929+
"""
930+
changed = set(self._changed_layer_ids)
931+
if not changed:
932+
return
933+
934+
def is_changed(config):
935+
layer = config.get('layer') if config else None
936+
try:
937+
return layer is not None and layer.id() in changed
938+
except RuntimeError:
939+
return False
940+
941+
if is_changed(self._basal_contacts):
942+
self.calculate_unique_basal_units()
943+
if is_changed(self._basal_contacts) or is_changed(self._structural_orientations):
944+
self.update_stratigraphy()
945+
if is_changed(self._fault_traces):
946+
self.update_faults()
947+
model_crs = self.get_model_crs()
948+
for layer, layer_data in self._manual_foliation_layer_rows():
949+
if layer.id() in changed:
950+
layer_data['df'] = qgsLayerToGeoDataFrame(layer, target_crs=model_crs)
951+
self._changed_layer_ids.clear()
952+
self._notify_layer_data_changed()
953+
954+
def refresh_model_data(self):
955+
"""Read the changed input layers again and put their data into the
956+
features already in the model, without Initialize Model.
957+
958+
Returns
959+
-------
960+
dict
961+
See `GeologicalModelManager.refresh_feature_data`.
962+
"""
963+
if self._model_manager is None:
964+
raise RuntimeError("Model manager is not set.")
965+
self.reload_changed_layers()
966+
return self._model_manager.refresh_feature_data()
967+
809968
def get_layers_outside_bounding_box(self):
810969
"""Check which input layers do not overlap the bounding box in XY.
811970
@@ -897,6 +1056,7 @@ def set_fault_trace_layer(
8971056
self.update_faults()
8981057
if self.fault_traces_callback:
8991058
self.fault_traces_callback(**self._fault_traces)
1059+
self.refresh_layer_watchers()
9001060

9011061
def get_fault_traces(self) -> Optional[FaultTracesConfig]:
9021062
"""Get the fault traces."""
@@ -922,6 +1082,7 @@ def set_structural_orientations(
9221082
if self.structural_orientations_callback:
9231083
self.structural_orientations_callback(**self._structural_orientations)
9241084
self.update_stratigraphy()
1085+
self.refresh_layer_watchers()
9251086

9261087
def get_structural_orientations(self) -> Optional[StructuralOrientationsConfig]:
9271088
"""Get the structural orientations."""
@@ -1140,6 +1301,9 @@ def reset(self):
11401301
self._model_manager.update_bounding_box(self._bounding_box)
11411302
self._model_manager.set_dem_function(self.dem_function)
11421303

1304+
self._changed_layer_ids.clear()
1305+
self.refresh_layer_watchers()
1306+
11431307
self.logger(message="Application state reset.", log_level=3)
11441308

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

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

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

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

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

0 commit comments

Comments
 (0)