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/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/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/data_manager.py b/loopstructural/main/data_manager.py index c1b727d..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): @@ -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) @@ -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) @@ -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.""" @@ -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.") 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 9babe02..c3aa7be 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`. @@ -170,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 @@ -224,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') @@ -255,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') @@ -836,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, @@ -878,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. @@ -1205,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' @@ -1270,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: @@ -1284,6 +1340,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() @@ -1294,6 +1351,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: @@ -1307,6 +1365,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. @@ -1503,6 +1655,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`. @@ -1516,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, @@ -1530,6 +1729,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 @@ -1595,13 +1804,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_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) 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) 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 == {}