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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions COMPAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ unchanged, but the new path is preferred going forward.
|---|---|---|
| `LoopStructural.modelling.features._feature_converters.add_fold_to_feature` (private module, imported directly by the QGIS plugin) | `GeologicalModel.add_fold_to_feature` | 2026-07-24 |
| `LoopStructural.modelling.features._feature_converters.convert_feature_to_structural_frame` | `GeologicalModel.convert_feature_to_structural_frame` | 2026-07-24 |
| `GeologicalModel.to_recipe_json()` / `to_recipe_dict()` / `save_recipe()` silently wrote a lossy recipe for any model: only `name`, `kind`, `faults` for each feature (build parameters dropped, kinds mislabelled), and a model with a fault, unconformity or custom `data=` rebuilt wrongly or not at all | Each foliation entry now stores the parameters it was requested with (`build`), `group_mapping` is stored, and `from_recipe_*` replays them, so a model built with non-default `nelements`, `interpolatortype` or `regularisation` rebuilds to the same field. Foliations, faults, unconformities (erode and onlap), fold frames, folded foliations and folded fold frames (refolded folds) are rebuilt. **A model containing any other feature (domain fault, intrusion, ...), a feature built with its own `data=`, or a parameter JSON cannot hold, now raises `LoopValueError` from `to_recipe_*` instead of writing a recipe that rebuilds wrongly.** Older recipes (no `build`) still load, with a warning, but an older entry whose kind is not `foliation` is refused | 2026-10-29 |
| `GeologicalModel.create_and_add_fault(..., faults=[])` | `GeologicalModel.create_and_add_fault(..., faults=None)` (list built internally, same effective default) | 2026-07-27 |
| `GeologicalModel.create_and_add_intrusion(..., intrusion_frame_parameters={}, geometric_scaling_parameters={})` | `GeologicalModel.create_and_add_intrusion(..., intrusion_frame_parameters=None, geometric_scaling_parameters=None)` (dicts built internally, same effective default) | 2026-07-27 |
| `GeologicalModel.get_fault_surfaces(faults=[])` | `GeologicalModel.get_fault_surfaces(faults=None)` (list built internally, same effective default) | 2026-07-27 |
Expand Down
10 changes: 9 additions & 1 deletion LoopStructural/modelling/core/_model_relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from ...modelling.features import FeatureType, UnconformityFeature
from ...utils import getLogger
from ._model_serializer import ModelSerializer

logger = getLogger(__name__)

Expand Down Expand Up @@ -159,6 +160,9 @@ def add_unconformity(model, feature, value, index=None):
else:
f.add_region(uc_feature)
# now add the unconformity to the feature list
ModelSerializer.record_creation(
uc_feature, "unconformity", {"feature": feature.name, "value": value, "onlap": False}
)
model._add_feature(uc_feature, index=index)
return uc_feature

Expand Down Expand Up @@ -195,6 +199,10 @@ def add_onlap_unconformity(model, feature, value, index=None):
continue
if f != feature:
f.add_region(uc_feature)
model._add_feature(uc_feature.inverse(), index=index)
in_model = uc_feature.inverse()
ModelSerializer.record_creation(
in_model, "unconformity", {"feature": feature.name, "value": value, "onlap": True}
)
model._add_feature(in_model, index=index)

return uc_feature
175 changes: 166 additions & 9 deletions LoopStructural/modelling/core/_model_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,134 @@
delegate to the staticmethods here.
"""

import copy
import json
import pathlib

import numpy as np
import pandas as pd

from ...geometry import BoundingBox
from ...utils import LoopValueError, getLogger
from ..features import GeologicalFeature, StructuralFrame, UnconformityFeature
from ..features.fault import FaultSegment
from ..features.fold import FoldFrame
from .stratigraphic_column import StratigraphicColumn

logger = getLogger(__name__)


# Parameters of create_and_add_feature that are not stored as they are: ``index`` is the position
# (the recipe keeps the order), ``data`` is a data frame (see ``custom_data``), and ``faults`` and
# ``fold_frame`` are other features, stored by name.
_NOT_RECORDED = ("index", "faults", "data", "fold_frame")

# Feature types a recipe can rebuild from the parameters stored with it, and the kind that
# ``feature_recipe_kind`` must report for a feature created as that type.
_SUPPORTED_FEATURE_TYPES = {
"foliation": "foliation",
"fault": "fault",
"unconformity": "unconformity",
"fold_frame": "fold_frame",
"folded_foliation": "foliation",
"folded_fold_frame": "fold_frame",
}


def _json_safe(value: object, where: str) -> object:
"""Return ``value`` as something ``json`` can write, or raise ``LoopValueError``."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, np.generic):
return value.item()
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, (list, tuple)):
return [_json_safe(v, where) for v in value]
if isinstance(value, dict):
return {str(k): _json_safe(v, f"{where}[{k!r}]") for k, v in value.items()}
raise LoopValueError(
f"{where} is a {type(value).__name__}, which cannot be written to a recipe. "
"Only numbers, strings, booleans, None, lists and dictionaries of these can be stored."
)


class ModelSerializer:
@staticmethod
def feature_recipe_kind(feature):
# subclasses first: FaultSegment is a StructuralFrame and UnconformityFeature a
# GeologicalFeature
if isinstance(feature, FaultSegment):
return "fault"
if isinstance(feature, UnconformityFeature):
return "unconformity"
if isinstance(feature, GeologicalFeature):
return "foliation"
if isinstance(feature, FoldFrame):
return "fold_frame"
if isinstance(feature, StructuralFrame):
return "structural_frame"
if isinstance(feature, UnconformityFeature):
return "unconformity"
if isinstance(feature, FaultSegment):
return "fault"
return feature.__class__.__name__.lower()

@staticmethod
def record_creation(feature: object, feature_type: str, params: dict) -> None:
"""Remember how ``feature`` was created, for the recipe.

Called by ``GeologicalModel.create_and_add_feature`` with the parameters that were
*requested* (for example ``nelements``, not the element count that was realised).
"""
# deep copy: a list/dict/array the caller changes later must not change the recipe
parameters = {
k: copy.deepcopy(v) for k, v in params.items() if k not in _NOT_RECORDED
}
faults = params.get("faults")
if faults is not None:
# [] means "no faults", which is different from None ("all faults in the model")
parameters["faults"] = [f if isinstance(f, str) else f.name for f in faults]
fold_frame = params.get("fold_frame")
if fold_frame is not None:
parameters["fold_frame"] = fold_frame if isinstance(fold_frame, str) else fold_frame.name
feature._recipe_creation = {
"feature_type": feature_type,
"parameters": parameters,
"custom_data": params.get("data") is not None,
}

@staticmethod
def feature_build_entry(feature: object) -> dict:
"""Return the ``build`` part of a feature's recipe entry.

Raises
------
LoopValueError
if the feature cannot be rebuilt from a recipe yet
"""
kind = ModelSerializer.feature_recipe_kind(feature)
creation = getattr(feature, "_recipe_creation", None)
if creation is None:
raise LoopValueError(
f"Feature '{feature.name}' ({kind}) cannot be stored in a recipe: there is no "
"record of how it was created. Recipes store features created with "
"GeologicalModel.create_and_add_feature (or a create_and_add_* method)."
)
feature_type = creation["feature_type"]
if _SUPPORTED_FEATURE_TYPES.get(feature_type) != kind:
raise LoopValueError(
f"Feature '{feature.name}' ({kind}, created as '{feature_type}') cannot be "
f"stored in a recipe yet: recipes support {sorted(_SUPPORTED_FEATURE_TYPES)}."
)
if creation["custom_data"]:
raise LoopValueError(
f"Feature '{feature.name}' was created with its own data=, which a recipe cannot "
"store: a recipe rebuilds a feature from the rows of the model data whose "
"feature_name matches. Put the data in model.data instead."
)
parameters = {
key: _json_safe(value, f"parameter '{key}' of feature '{feature.name}'")
for key, value in creation["parameters"].items()
}
return {"feature_type": feature_type, "parameters": parameters}

@staticmethod
def to_dict(model):
result = {}
Expand Down Expand Up @@ -68,6 +169,7 @@ def to_recipe_dict(model, data_reference=None):
if getattr(fault, "name", None)
],
"regions": [],
"build": ModelSerializer.feature_build_entry(feature),
}
recipe["model"]["features"].append(feature_entry)
if data_reference is not None:
Expand Down Expand Up @@ -132,10 +234,63 @@ def from_recipe_dict(cls, recipe):
feature_name = feature_entry.get("name")
if not isinstance(feature_name, str):
raise TypeError("each feature entry must include a string name")
feature_data = model.data.loc[model.data["feature_name"] == feature_name].copy()
if feature_data.empty:
feature_data = None
feature = model.create_and_add_foliation(feature_name, data=feature_data)
build = feature_entry.get("build")
if build is None:
# a recipe written before build parameters were stored
kind = feature_entry.get("kind", "foliation")
if kind != "foliation":
raise LoopValueError(
f"Cannot rebuild feature '{feature_name}' of kind '{kind}' from this "
"recipe: it has no build parameters, and only foliations can be rebuilt "
"without them."
)
logger.warning(
f"The recipe has no build parameters for feature '{feature_name}' (it was "
"written by an older version); building it with the default parameters"
)
build = {"feature_type": "foliation", "parameters": {}}
if not isinstance(build, dict) or not isinstance(build.get("parameters", {}), dict):
raise TypeError("a feature's build entry must be a dictionary with parameters")
feature_type = build.get("feature_type", "foliation")
parameters = dict(build.get("parameters", {}))
if "faults" in parameters:
fault_names = parameters["faults"]
missing = [n for n in fault_names if n not in feature_map]
if missing:
raise LoopValueError(
f"Feature '{feature_name}' refers to {missing}, which are not earlier "
"entries of the recipe"
)
parameters["faults"] = [feature_map[n] for n in fault_names]
if "fold_frame" in parameters:
frame_name = parameters["fold_frame"]
if frame_name not in feature_map:
raise LoopValueError(
f"Feature '{feature_name}' uses the fold frame '{frame_name}', which is "
"not an earlier entry of the recipe"
)
parameters["fold_frame"] = feature_map[frame_name]
if feature_type == "unconformity":
base = feature_map.get(parameters.get("feature"))
if base is None:
raise LoopValueError(
f"Unconformity '{feature_name}' refers to '{parameters.get('feature')}', "
"which is not an earlier entry of the recipe"
)
add = (
model.add_onlap_unconformity if parameters.get("onlap") else model.add_unconformity
)
if "value" not in parameters:
raise LoopValueError(
f"Unconformity '{feature_name}' has no 'value' in its recipe parameters"
)
feature = add(base, parameters["value"])
if parameters.get("onlap"):
# add_onlap_unconformity returns the unconformity applied to the other
# features; the model holds its inverse
feature = model.get_feature_by_name(feature_name)
else:
feature = model.create_and_add_feature(feature_type, feature_name, **parameters)
if feature is None:
raise ValueError(f"Could not recreate feature '{feature_name}' from recipe")
feature_map[feature_name] = feature
Expand All @@ -145,7 +300,9 @@ def from_recipe_dict(cls, recipe):
fault_names = feature_entry.get("faults", [])
if not isinstance(fault_names, list):
raise TypeError("faults must be a list")
if fault_names:
# restores faults assigned to a foliation after it was built; faults, unconformities
# and features given an explicit faults list were built with them
if fault_names and feature_entry.get("kind", "foliation") == "foliation":
feature = feature_map[feature_name]
feature.faults = [feature_map[name] for name in fault_names if name in feature_map]

Expand Down
21 changes: 20 additions & 1 deletion LoopStructural/modelling/core/geological_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,26 @@ def create_and_add_feature(self, feature_type: str, name: str, **params):
feature : BaseFeature
the created geological feature, or None if it could not be built
"""
return FeatureBuilderRegistry.create(feature_type, self, name, **params)
record_params = dict(params)
if feature_type == "foliation" and record_params.get("faults") is None:
# faults=None means every fault in the model when the feature is built, which is not
# recoverable from the feature list order once index= has been used: record the
# faults that are actually used (in model order, as add_faults reads them)
record_params["faults"] = [
f.name for f in self.features if f.type == FeatureType.FAULT
]
if (
feature_type in ("folded_foliation", "folded_fold_frame")
and record_params.get("fold_frame") is None
and len(self.features) > 0
):
# fold_frame=None means "the last feature in the model" when the feature is built;
# record that name so the recipe does not depend on the order of the feature list
record_params["fold_frame"] = self.features[-1].name
feature = FeatureBuilderRegistry.create(feature_type, self, name, **params)
if feature is not None:
ModelSerializer.record_creation(feature, feature_type, record_params)
return feature

@public_api(tier="stable")
def create_and_add_foliation(
Expand Down
3 changes: 3 additions & 0 deletions LoopStructural/modelling/core/stratigraphic_column.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ def to_dict(self):
"""
return {
"elements": [element.to_dict() for element in self.order],
"group_mapping": dict(self.group_mapping),
}

def update_from_dict(self, data):
Expand All @@ -658,6 +659,7 @@ def update_from_dict(self, data):
else:
element = StratigraphicUnit.from_dict(element_data)
self.add_element(element)
self.group_mapping = dict(data.get("group_mapping", {}))

@classmethod
def from_dict(cls, data):
Expand All @@ -675,6 +677,7 @@ def from_dict(cls, data):
else:
element = StratigraphicUnit.from_dict(element_data)
column.add_element(element)
column.group_mapping = dict(data.get("group_mapping", {}))
return column

def get_isovalues(self, where: str = 'bottom') -> dict[str, float]:
Expand Down
10 changes: 9 additions & 1 deletion LoopStructural/modelling/features/builders/_base_builder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import numpy as np

from LoopStructural.utils import getLogger

logger = getLogger(__name__)
Expand Down Expand Up @@ -57,7 +59,13 @@ def update_build_arguments(self, build_arguments):
for k, i in build_arguments.items():
logger.info(f"Setting {k} to {i} for {self.name}")
logger.info(f"{k} is currrently {self._build_arguments.get(k, None)}")
if i != self._build_arguments.get(k, None):
current = self._build_arguments.get(k, None)
# `!=` on a numpy array is elementwise and has no single truth value
if isinstance(i, np.ndarray) or isinstance(current, np.ndarray):
changed = not np.array_equal(i, current)
else:
changed = i != current
if changed:
logger.info(f"Setting {k} to {i} for {self.name}")
self._build_arguments[k] = i
## if build_arguments change then flag to reinterpolate
Expand Down
Loading
Loading