From b713bd11838d2b8ab4dae47d4eb886d8da10f165 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 29 Sep 2026 12:35:51 +0930 Subject: [PATCH 1/3] 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. --- loopstructural/main/data_manager.py | 5 ++ loopstructural/main/model_manager.py | 82 ++++++++++++++++++++++++++++ tests/qgis/test_manual_foliations.py | 46 ++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index c1b727d..a189d9c 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1163,6 +1163,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) @@ -1191,6 +1192,10 @@ 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', {})) self.logger(message=f"Loaded application state from '{path}'.", log_level=3) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 9babe02..ab4635c 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -100,6 +100,39 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat return df +def _json_value(value): + """Convert a column value to a type that `json.dump` can write.""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, np.generic): + return value.item() + # QGIS NULL (a null QVariant) + is_null = getattr(value, 'isNull', None) + if callable(is_null) and is_null(): + return None + return str(value) + + +def _geodataframe_to_dict(gdf: Optional[gpd.GeoDataFrame]) -> Optional[dict]: + """Write `gdf` as WKT geometries (Z kept) and plain column values.""" + if gdf is None: + return None + columns = [c for c in gdf.columns if c != gdf.geometry.name] + return { + 'crs': gdf.crs.to_string() if gdf.crs is not None else None, + 'geometry': [None if g is None else g.wkt for g in gdf.geometry], + 'columns': {str(c): [_json_value(v) for v in gdf[c]] for c in columns}, + } + + +def _geodataframe_from_dict(data: Optional[dict]) -> Optional[gpd.GeoDataFrame]: + """Read a GeoDataFrame written by `_geodataframe_to_dict`.""" + if data is None: + return None + geometry = gpd.GeoSeries.from_wkt(data['geometry']) + return gpd.GeoDataFrame(data['columns'], geometry=geometry, crs=data.get('crs')) + + def _form_line_tangent_vectors(df: pd.DataFrame) -> np.ndarray: """Per-vertex unit tangent (tx, ty, tz) along each digitised line in `df`. @@ -1503,6 +1536,55 @@ def remove_manual_foliation(self, name: str): """ self.manual_foliations.pop(name, None) + def manual_foliations_to_dict(self) -> dict: + """Return `manual_foliations` in a form that `json.dump` can write. + + Each layer's 'df' is written as WKT geometries and plain column + values, and the QGIS 'layer' object is left out (a rebuild uses only + 'df'). The sampler is not kept: `manual_foliations_from_dict` always + uses `AllSampler`, which is the only sampler `add_foliation` gets. + """ + result = {} + for name, spec in self.manual_foliations.items(): + data = {} + for key, layer_data in spec['data'].items(): + layer_dict = {k: v for k, v in layer_data.items() if k not in ('layer', 'df')} + layer_dict['df'] = _geodataframe_to_dict(layer_data.get('df')) + data[key] = layer_dict + result[name] = { + 'data': data, + 'folded_feature_name': spec.get('folded_feature_name'), + 'use_z_coordinate': spec.get('use_z_coordinate', False), + 'restrict_to_stratigraphic_domain': spec.get( + 'restrict_to_stratigraphic_domain', True + ), + } + return result + + def manual_foliations_from_dict(self, manual_foliations: dict): + """Replace `manual_foliations` with the specs from + `manual_foliations_to_dict`. + + Does not change the current model: `update_model` uses these specs + the next time it builds the model. + """ + self.manual_foliations = {} + for name, spec in (manual_foliations or {}).items(): + data = {} + for key, layer_dict in spec.get('data', {}).items(): + layer_data = dict(layer_dict) + layer_data['df'] = _geodataframe_from_dict(layer_dict.get('df')) + data[key] = layer_data + self.manual_foliations[name] = { + 'data': data, + 'folded_feature_name': spec.get('folded_feature_name'), + 'sampler': AllSampler(), + 'use_z_coordinate': spec.get('use_z_coordinate', False), + 'restrict_to_stratigraphic_domain': spec.get( + 'restrict_to_stratigraphic_domain', True + ), + } + def _build_manual_foliations(self): """Build again every foliation added with `add_foliation`. diff --git a/tests/qgis/test_manual_foliations.py b/tests/qgis/test_manual_foliations.py index bc05c8d..f699818 100644 --- a/tests/qgis/test_manual_foliations.py +++ b/tests/qgis/test_manual_foliations.py @@ -90,3 +90,49 @@ def test_reset_forgets_manual_foliations(self, manager): manager.reset() assert manager.manual_foliations == {} + + +class TestManualFoliationsSerialisation: + def _round_trip(self, manager): + import json + + return json.loads(json.dumps(manager.manual_foliations_to_dict())) + + def test_round_trip_keeps_the_options(self, manager): + manager.add_foliation( + 's1', + {'values': _value_layer()}, + use_z_coordinate=True, + restrict_to_stratigraphic_domain=False, + ) + + other = GeologicalModelManager(debug_manager=_DebugManager()) + other.manual_foliations_from_dict(self._round_trip(manager)) + + spec = other.manual_foliations['s1'] + assert spec['use_z_coordinate'] is True + assert spec['restrict_to_stratigraphic_domain'] is False + layer = spec['data']['values'] + assert layer['type'] == 'Value' + assert layer['value_field'] == 'value' + original = manager.manual_foliations['s1']['data']['values']['df'] + assert list(layer['df']['value']) == list(original['value']) + assert all(g.has_z for g in layer['df'].geometry) + + def test_qgis_layer_object_is_not_written(self, manager): + data = {'values': {**_value_layer(), 'layer': object()}} + manager.add_foliation('s1', data, use_z_coordinate=True) + + written = self._round_trip(manager) + + assert 'layer' not in written['s1']['data']['values'] + + def test_loaded_foliation_is_built_again_by_update_model(self, manager): + manager.add_foliation('s1', {'values': _value_layer()}, use_z_coordinate=True) + written = self._round_trip(manager) + manager.manual_foliations = {} + + manager.manual_foliations_from_dict(written) + manager.update_model(notify_observers=False) + + assert 's1' in _names(manager) From 1f4c7284acbe8e2ef1d4569ec8859fa3221a9084 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 29 Sep 2026 13:27:54 +0930 Subject: [PATCH 2/3] 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. --- .../geological_model_tab.py | 67 ++++- loopstructural/main/data_manager.py | 169 ++++++++++++ loopstructural/main/model_manager.py | 185 ++++++++++--- tests/qgis/test_refresh_feature_data.py | 254 ++++++++++++++++++ 4 files changed, 642 insertions(+), 33 deletions(-) create mode 100644 tests/qgis/test_refresh_feature_data.py 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 d514e12..564b8e7 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index a189d9c..705ff81 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,5 +1,6 @@ import json from collections import defaultdict +from functools import partial from pathlib import Path from typing import Optional @@ -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.""" @@ -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 ( @@ -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. @@ -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.""" @@ -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.""" @@ -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): @@ -1196,6 +1360,9 @@ def load_state(self, filepath): # 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) @@ -1471,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.""" @@ -1510,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.") diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index ab4635c..663a4ea 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -203,6 +203,13 @@ def __init__(self, debug_manager=None): # parameter tweak, `update_all_features`/Solve Model can't pick them # up -- see `set_fault_topology`. self._topology_dirty = False + # True once `refresh_feature_data` found input data that it cannot + # put into the current features (a fault trace changed, or a group + # got or lost all of its data). Only Initialize Model applies that. + self._data_dirty = False + # fault name -> the fault data that the last `update_model` built + # the fault from; `refresh_feature_data` compares against it. + self._built_fault_data: Dict[str, pd.DataFrame] = {} # Set by request_cancel() and checked in _report_progress; lets a # running Initialize/Solve be stopped between fault/feature builds. self._cancel_requested = False @@ -257,6 +264,8 @@ def reset(self): self.manual_foliations = {} self.dem_function = lambda x, y: 0 self._topology_dirty = False + self._data_dirty = False + self._built_fault_data = {} self._emit('model_updated') self._emit('model_update_finished') @@ -288,6 +297,10 @@ def load_model(self, filepath): # topology edit against it yet, so don't carry over a stale flag # from whatever model was previously loaded. self._topology_dirty = False + self._data_dirty = False + # the data manager restores the fault data from the same state + # before it loads the model, so take that as the built data + self._built_fault_data = self._copy_fault_data() self._emit('model_updated') self._emit('model_update_finished') @@ -869,31 +882,14 @@ def update_foliation_features(self): if qgisAttributeIsNone(group) is None: self._debug_manager.log(f"Group {group.name} has no data, skipping.", log_level=2) continue - data = [] groupname = group.name stratigraphic_column[groupname] = {} - for u in group.units: - val = isovalues[u.name]['value'] - unit_data = self.stratigraphy.get(u.name, None) - if unit_data is not None: - if 'contact' in unit_data: - contact = unit_data['contact'] - if not contact.empty: - contact['val'] = val - contact['feature_name'] = groupname - data.append(contact) - if 'orientations' in unit_data: - orientations = unit_data['orientations'] - if not orientations.empty: - orientations['val'] = np.nan - orientations['feature_name'] = groupname - data.append(orientations) - if len(data) == 0: + data = self._group_data(group, isovalues) + if data is None: self._debug_manager.log( f"No data found for group {groupname}, skipping.", log_level=2 ) continue - data = pd.concat(data, ignore_index=True) foliation = self.model.create_and_add_foliation( groupname, data=data, @@ -911,6 +907,34 @@ def update_foliation_features(self): # foliation features were rebuilt; let observers know self._emit('foliation_features_updated') + def _group_data(self, group, isovalues) -> Optional[pd.DataFrame]: + """Return the contact and orientation data of the units in `group` + as one data frame for its foliation, or None if there is no data. + + `isovalues` is `stratigraphic_column.get_isovalues()`. + """ + data = [] + for u in group.units: + val = isovalues[u.name]['value'] + unit_data = self.stratigraphy.get(u.name, None) + if unit_data is None: + continue + if 'contact' in unit_data: + contact = unit_data['contact'] + if not contact.empty: + contact['val'] = val + contact['feature_name'] = group.name + data.append(contact) + if 'orientations' in unit_data: + orientations = unit_data['orientations'] + if not orientations.empty: + orientations['val'] = np.nan + orientations['feature_name'] = group.name + data.append(orientations) + if len(data) == 0: + return None + return pd.concat(data, ignore_index=True) + def _strip_spurious_regions_from_domain_faults(self): """Work around a LoopStructural core gap that corrupts a domain fault's own scalar field. @@ -1238,16 +1262,17 @@ def is_feature_built(self, feature, _seen: Optional[set] = None) -> Optional[boo def model_state(self) -> str: """Coarse summary of the model's build state, for display in the GUI. - Returns 'empty' (no features yet), 'stale' (fault topology changed - since the last Initialize Model -- Solve Model alone can't apply - that, see `_on_fault_topology_changed`), 'initialized' (features + Returns 'empty' (no features yet), 'stale' (fault topology or input + data changed since the last Initialize Model in a way Solve Model + alone can't apply, see `_on_fault_topology_changed` and + `refresh_feature_data`), 'initialized' (features exist but at least one hasn't been solved) or 'solved' (everything is up to date). """ features = [f for f in self.features() if not f.name.startswith('__')] if not features: return 'empty' - if getattr(self, '_topology_dirty', False): + if getattr(self, '_topology_dirty', False) or getattr(self, '_data_dirty', False): return 'stale' if all(self.is_feature_built(f) for f in features): return 'solved' @@ -1317,6 +1342,7 @@ def update_model( ) except Exception: pass + self._built_fault_data = self._copy_fault_data() try: # Update the model with stratigraphy self.update_fault_features() @@ -1327,6 +1353,7 @@ def update_model( # fault topology (abutting/faulted/stratigraphy relationships) was # just re-applied above, so any pending topology edit is now current self._topology_dirty = False + self._data_dirty = False finally: self._progress_callback = None if dbg is not None: @@ -1340,6 +1367,100 @@ def update_model( self._emit('model_updated') self._emit('model_update_finished') + def _copy_fault_data(self) -> Dict[str, pd.DataFrame]: + """Return a copy of the data of each fault in `faults`.""" + return { + name: fault_data['data'].copy() + for name, fault_data in self.faults.items() + if 'data' in fault_data + } + + def refresh_feature_data(self) -> dict: + """Put the current input data into the features already in the model. + + Unlike `update_model` (Initialize Model), this does not clear and + build the features again, so the changes the user made to a feature + (interpolator settings, a fold, a conversion to a structural frame, + the regions of a foliation) stay. A foliation whose data changed is + marked as not built, so Solve Model interpolates it again with the + new data. + + Some changes cannot go into an existing feature: a changed fault + trace (the fault geometry comes from the trace when the fault is + created), or a group that got or lost all of its data. For these, + `model_state` becomes 'stale' until Initialize Model runs again. + + Returns + ------- + dict + 'updated': names of the features that got new data; + 'needs_initialize': names of the features that need Initialize + Model to use the new data. + """ + updated = [] + needs_initialize = [] + + def refresh(name, data): + feature = self._get_feature_by_name_or_none(name) + if data is None and feature is None: + return + if data is None or feature is None: + needs_initialize.append(name) + return + builder = getattr(feature, 'builder', None) + # a structural frame holds the original feature data in its + # first coordinate + target = builder.builders[0] if hasattr(builder, 'builders') else builder + if target is None or not hasattr(target, 'add_data_from_data_frame'): + needs_initialize.append(name) + return + data = self.model.prepare_data(data, include_feature_name=False) + current = getattr(target, 'data', None) + if isinstance(current, pd.DataFrame) and current.equals(data): + return + target.add_data_from_data_frame(data) + # add_data_from_data_frame does not reset this flag, and while + # it is set the builder keeps the constraints of the old data + target.data_added = False + builder.set_not_up_to_date(self) + updated.append(name) + + if 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: + continue + refresh(group.name, self._group_data(group, isovalues)) + + for name, spec in self.manual_foliations.items(): + try: + data, _kwargs = self._foliation_data( + name, + spec['data'], + spec.get('sampler', AllSampler()), + spec.get('use_z_coordinate', False), + ) + except Exception as e: + if self._debug_manager is not None: + self._debug_manager.log( + f"Could not read the data of foliation '{name}': {e}", log_level=2 + ) + continue + refresh(name, data) + + for name in set(self.faults) | set(self._built_fault_data): + 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: + continue + if new_data is None or built_data is None or not new_data.equals(built_data): + needs_initialize.append(name) + + if needs_initialize: + self._data_dirty = True + self._emit('model_updated') + return {'updated': updated, 'needs_initialize': needs_initialize} + def update_feature(self, feature_name: str): """Update a specific feature in the geological model. @@ -1612,6 +1733,16 @@ def _create_foliation( restrict_to_stratigraphic_domain=True, ): """Create the foliation feature in the model; see `add_foliation`.""" + data, kwargs = self._foliation_data(name, data, sampler, use_z_coordinate) + foliation = self.model.create_and_add_foliation(name, data=data, **kwargs) + if not restrict_to_stratigraphic_domain: + foliation.regions = [ + r for r in foliation.regions if not isinstance(r, UnconformityFeature) + ] + + def _foliation_data(self, name, data, sampler=AllSampler(), use_z_coordinate=False): + """Return the data frame and the extra `create_and_add_foliation` + arguments for the foliation `name` from its layer dicts `data`.""" dfs = [] kwargs = {} interface_offset = 0 @@ -1677,13 +1808,7 @@ def _create_foliation( kwargs['solver'] = 'admm' else: raise ValueError(f"Unknown layer type: {layer_data['type']}") - foliation = self.model.create_and_add_foliation( - name, data=pd.concat(dfs, ignore_index=True), **kwargs - ) - if not restrict_to_stratigraphic_domain: - foliation.regions = [ - r for r in foliation.regions if not isinstance(r, UnconformityFeature) - ] + return pd.concat(dfs, ignore_index=True), kwargs def add_unconformity( self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY diff --git a/tests/qgis/test_refresh_feature_data.py b/tests/qgis/test_refresh_feature_data.py new file mode 100644 index 0000000..bd8a62e --- /dev/null +++ b/tests/qgis/test_refresh_feature_data.py @@ -0,0 +1,254 @@ +"""Pytest tests for putting changed input data into an existing model. + +`GeologicalModelManager.refresh_feature_data` puts the current input data +into the features that are already in the model, so the changes the user +made to those features stay (Initialize Model builds every feature again). +`ModellingDataManager` watches the input layers and reads the changed ones +again before that. +""" + +from unittest.mock import Mock + +import geopandas as gpd +import numpy as np +import pandas as pd +import pytest +from LoopStructural import StratigraphicColumn +from LoopStructural.datatypes import BoundingBox +from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsFeature, + QgsGeometry, + QgsPoint, + QgsProject, + QgsVectorLayer, +) +from shapely.geometry import Point + +from loopstructural.main.data_manager import ModellingDataManager +from loopstructural.main.model_manager import GeologicalModelManager +from loopstructural.toolbelt.preferences import PlgSettingsStructure + +MODEL_CRS = "EPSG:32755" +POINTS = [(x, y) for x in (20.0, 50.0, 80.0) for y in (20.0, 50.0, 80.0)] + + +def _value_layer(scale=10.0): + points = [Point(x, y, 0.0) for x, y in POINTS] + gdf = gpd.GeoDataFrame({'value': [p.x / scale 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: + """update_model logs through the debug manager, which the plugin always sets.""" + + def log(self, *args, **kwargs): + pass + + +@pytest.fixture +def manager(monkeypatch): + 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 = StratigraphicColumn() + return manager + + +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 + + +class TestRefreshManualFoliation: + def test_changed_data_goes_into_the_same_feature(self, manager): + manager.add_foliation('s1', {'values': _value_layer()}, use_z_coordinate=True) + manager.update_all_features(notify_observers=False) + feature = manager.model['s1'] + before = feature.evaluate_value(np.array([[50.0, 50.0, 0.0]])) + + manager.manual_foliations['s1']['data']['values'] = _value_layer(scale=1.0) + result = manager.refresh_feature_data() + + assert result == {'updated': ['s1'], 'needs_initialize': []} + assert manager.model['s1'] is feature + assert manager.is_feature_built(feature) is False + assert manager.model_state == 'initialized' + manager.update_all_features(notify_observers=False) + after = feature.evaluate_value(np.array([[50.0, 50.0, 0.0]])) + assert not np.allclose(before, after) + + def test_unchanged_data_keeps_the_feature_built(self, manager): + manager.add_foliation('s1', {'values': _value_layer()}, use_z_coordinate=True) + manager.update_all_features(notify_observers=False) + + result = manager.refresh_feature_data() + + assert result == {'updated': [], 'needs_initialize': []} + assert manager.is_feature_built(manager.model['s1']) is True + + +class TestRefreshStratigraphy: + def test_changed_contacts_go_into_the_group_foliation(self, manager): + 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() + manager.update_model(notify_observers=False) + group_name = manager.stratigraphic_column.get_groups()[0].name + feature = manager.model[group_name] + + manager.stratigraphy['lower']['contact'] = _contact(-20.0) + result = manager.refresh_feature_data() + + assert result == {'updated': [group_name], 'needs_initialize': []} + assert manager.model[group_name] is feature + assert (feature.builder.data['Z'] == -20.0).any() + + def test_group_with_new_data_needs_initialize(self, manager): + manager.stratigraphic_column = _one_group_column() + manager.update_model(notify_observers=False) + manager.add_foliation('s1', {'values': _value_layer()}, use_z_coordinate=True) + group_name = manager.stratigraphic_column.get_groups()[0].name + + manager.stratigraphy['lower']['contact'] = _contact(-10.0) + result = manager.refresh_feature_data() + + assert result['needs_initialize'] == [group_name] + assert manager.model_state == 'stale' + + +class TestRefreshFaults: + @pytest.fixture + def fault_manager(self, manager, monkeypatch): + # 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) + manager.update_model(notify_observers=False) + return manager + + def test_unchanged_fault_needs_nothing(self, fault_manager): + result = fault_manager.refresh_feature_data() + + assert result['needs_initialize'] == [] + assert fault_manager.model_state != 'stale' + + def test_changed_fault_trace_needs_initialize(self, fault_manager): + fault_manager.faults['f1']['data'] = pd.DataFrame( + {'X': [10.0, 90.0], 'Y': [40.0, 60.0], 'Z': [0.0, 0.0]} + ) + + result = fault_manager.refresh_feature_data() + + assert result['needs_initialize'] == ['f1'] + assert fault_manager.model_state == 'stale' + fault_manager.update_model(notify_observers=False) + assert fault_manager.model_state != 'stale' + + +@pytest.fixture +def value_layer(): + """A point layer with a 'value' field, in the project like a real input layer.""" + layer = QgsVectorLayer(f"PointZ?crs={MODEL_CRS}&field=value:double", 'values', "memory") + features = [] + for x, y in POINTS: + feature = QgsFeature(layer.fields()) + feature.setGeometry(QgsGeometry(QgsPoint(x, y, 0.0))) + feature.setAttributes([x / 10.0]) + features.append(feature) + layer.dataProvider().addFeatures(features) + layer.updateExtents() + QgsProject.instance().addMapLayer(layer) + layer_id = layer.id() + yield layer + if QgsProject.instance().mapLayer(layer_id) is not None: + QgsProject.instance().removeMapLayer(layer_id) + + +@pytest.fixture +def data_manager(manager): + data_manager = ModellingDataManager( + project=QgsProject.instance(), mapCanvas=Mock(), logger=Mock() + ) + data_manager.set_model_manager(manager) + data_manager.set_model_crs(QgsCoordinateReferenceSystem(MODEL_CRS), use_project_crs=False) + return data_manager + + +def _add_foliation_from_layer(data_manager, layer): + data_manager.update_feature_data( + 's1', {'layer': layer, 'layer_name': layer.name(), 'type': 'Value', 'value_field': 'value'} + ) + data_manager.add_foliation_to_model('s1') + + +def _double_values(layer): + layer.startEditing() + for feature in layer.getFeatures(): + layer.changeAttributeValue(feature.id(), 0, feature['value'] * 2.0) + assert layer.commitChanges() + + +class TestLayerWatching: + def test_changed_layer_is_flagged(self, data_manager, value_layer): + layer = value_layer + _add_foliation_from_layer(data_manager, layer) + callback = Mock() + data_manager.add_layer_data_changed_callback(callback) + assert data_manager.get_changed_layers() == [] + + _double_values(layer) + + assert data_manager.get_changed_layers() == ['values'] + callback.assert_called() + + def test_edit_in_the_edit_buffer_is_flagged(self, data_manager, value_layer): + layer = value_layer + _add_foliation_from_layer(data_manager, layer) + + layer.startEditing() + feature = next(layer.getFeatures()) + layer.changeAttributeValue(feature.id(), 0, 100.0) + + assert data_manager.get_changed_layers() == ['values'] + layer.rollBack() + + def test_refresh_model_data_updates_the_foliation(self, data_manager, manager, value_layer): + layer = value_layer + _add_foliation_from_layer(data_manager, layer) + manager.update_all_features(notify_observers=False) + feature = manager.model['s1'] + + _double_values(layer) + result = data_manager.refresh_model_data() + + assert result['updated'] == ['s1'] + assert manager.model['s1'] is feature + assert data_manager.get_changed_layers() == [] + df = manager.manual_foliations['s1']['data']['values']['df'] + assert sorted(df['value']) == sorted(2.0 * x / 10.0 for x, _ in POINTS) + + def test_removed_layer_is_not_watched(self, data_manager, value_layer): + layer = value_layer + _add_foliation_from_layer(data_manager, layer) + _double_values(layer) + assert data_manager.get_changed_layers() == ['values'] + + QgsProject.instance().removeMapLayer(layer.id()) + + assert data_manager.get_changed_layers() == [] + assert data_manager._watched_layers == {} From b99de2087638f7d3e15353597a0bdcc8c96e2a8a Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 29 Sep 2026 15:41:41 +0930 Subject: [PATCH 3/3] style: apply black formatting --- loopstructural/gui/map2loop_tools/sorter_widget.py | 4 +++- .../feature_details_panel/_base.py | 1 - .../geological_model_tab/layer_selection_table.py | 4 +--- loopstructural/main/m2l_api.py | 12 ++++++++---- loopstructural/main/model_manager.py | 8 ++------ tests/qgis/test_dem_function.py | 4 +--- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index e14930a..b4b8bde 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -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, diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py index 086b0d5..c2a4f9d 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py @@ -742,4 +742,3 @@ def schedule_rebuild(self): obs() except Exception: pass - diff --git a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py index 16b5a5c..2e01dc6 100644 --- a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py +++ b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py @@ -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() diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 4206fde..c1b0389 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -328,8 +328,8 @@ def extract_basal_contacts( ].reset_index(drop=True) if unit_colours and basal_contacts.empty is False and 'basal_unit' in basal_contacts.columns: colours_by_name = {str(name).strip(): colour for name, colour in unit_colours.items()} - basal_contacts['colour'] = basal_contacts['basal_unit'].astype(str).str.strip().map( - colours_by_name + basal_contacts['colour'] = ( + basal_contacts['basal_unit'].astype(str).str.strip().map(colours_by_name) ) if all_contacts: return {'basal_contacts': basal_contacts, 'all_contacts': all_contacts_result} @@ -780,7 +780,9 @@ def calculate_thickness( "contacts can be calculated from the geology layer." ) if updater: - updater("The basal contacts layer has no features; calculating them from geology...") + updater( + "The basal contacts layer has no features; calculating them from geology..." + ) basal_contacts_gdf = None if basal_contacts_gdf is None: # No basal contacts layer supplied -- derive it from the geology @@ -816,7 +818,9 @@ def calculate_thickness( if updater: updater(f"Sampling basal contacts at spacing {sampling_frequency}...") - sampler = SamplerSpacing(spacing=sampling_frequency, dtm_data=dtm_gdal, geology_data=geology_gdf) + sampler = SamplerSpacing( + spacing=sampling_frequency, dtm_data=dtm_gdal, geology_data=geology_gdf + ) sampled_contacts_gdf = sampler.sample(basal_contacts_gdf) if sampled_contacts_gdf is None or len(sampled_contacts_gdf) == 0: raise ValueError( diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 663a4ea..c3aa7be 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1328,9 +1328,7 @@ def update_model( ) self._progress_callback = progress_callback displacement_fault_count = len(set(self.faults) - set(self.fault_boundaries.values())) - self._progress_total = ( - displacement_fault_count + group_count + len(self.manual_foliations) - ) + self._progress_total = displacement_fault_count + group_count + len(self.manual_foliations) self._progress_current = 0 dbg = getattr(self, '_debug_manager', None) if dbg is not None: @@ -1719,9 +1717,7 @@ def _build_manual_foliations(self): self._create_foliation(name, **spec) except Exception as e: if self._debug_manager is not None: - self._debug_manager.log( - f"Could not build foliation '{name}': {e}", log_level=2 - ) + self._debug_manager.log(f"Could not build foliation '{name}': {e}", log_level=2) def _create_foliation( self, diff --git a/tests/qgis/test_dem_function.py b/tests/qgis/test_dem_function.py index 66eb5de..97096c6 100644 --- a/tests/qgis/test_dem_function.py +++ b/tests/qgis/test_dem_function.py @@ -36,9 +36,7 @@ def data_manager(tmp_path): _write_geographic_dem(path) dem_layer = QgsRasterLayer(str(path), "dem") assert dem_layer.isValid() - manager = ModellingDataManager( - project=QgsProject.instance(), mapCanvas=Mock(), logger=Mock() - ) + manager = ModellingDataManager(project=QgsProject.instance(), mapCanvas=Mock(), logger=Mock()) manager.set_model_manager(Mock()) manager.set_model_crs(QgsCoordinateReferenceSystem(MODEL_CRS), use_project_crs=False) manager.set_dem_layer(dem_layer)