From ae90a97149873f99dae7de712f8221dfdf88a1bd Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 29 Oct 2026 09:22:35 +1030 Subject: [PATCH 1/2] refactor: dedupe the StructuredGrid results container and rename it PropertyGrid LoopStructural.geometry carried a copy of loop_common.geometry.StructuredGrid. Keep one class in loop_common (with the fuller docstrings), re-export it from LoopStructural.geometry, and rename it PropertyGrid because it is a result container, not the interpolation support loop_common.supports.StructuredGrid. StructuredGrid stays as an alias, so existing imports and pickles keep working. Co-Authored-By: Claude Sonnet 5.5 --- COMPAT.md | 1 + LoopStructural/geometry/__init__.py | 4 +- LoopStructural/geometry/_structured_grid.py | 195 +----------------- .../modelling/core/_model_exporter.py | 8 +- .../features/_base_geological_feature.py | 8 +- .../src/loop_common/geometry/__init__.py | 3 +- .../loop_common/geometry/_structured_grid.py | 101 ++++++++- 7 files changed, 113 insertions(+), 207 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 11ee6262a..206406338 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -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 | +| `LoopStructural.geometry.StructuredGrid` / `loop_common.geometry.StructuredGrid` (results container with `properties`/`cell_properties`) | `PropertyGrid` (same module; `StructuredGrid` stays as an alias). `LoopStructural.geometry` now re-exports the `loop_common` class instead of carrying a duplicate copy | 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 | diff --git a/LoopStructural/geometry/__init__.py b/LoopStructural/geometry/__init__.py index 10e08a338..c42a9cfc1 100644 --- a/LoopStructural/geometry/__init__.py +++ b/LoopStructural/geometry/__init__.py @@ -1,5 +1,7 @@ from loop_common.geometry import ( BoundingBox, + PropertyGrid, + StructuredGrid, StructuredGrid2DGeometry, StructuredGrid3DGeometry, Surface, @@ -10,7 +12,6 @@ ) from ..utils._api_registry import register_external_stable -from ._structured_grid import StructuredGrid for _cls in (BoundingBox, Surface, ValuePoints, VectorPoints): register_external_stable(f"LoopStructural.geometry.{_cls.__name__}", _cls.__init__) @@ -18,6 +19,7 @@ __all__ = [ "BoundingBox", + "PropertyGrid", "StructuredGrid", "StructuredGrid2DGeometry", "StructuredGrid3DGeometry", diff --git a/LoopStructural/geometry/_structured_grid.py b/LoopStructural/geometry/_structured_grid.py index f7b4b1dac..3d55f27a5 100644 --- a/LoopStructural/geometry/_structured_grid.py +++ b/LoopStructural/geometry/_structured_grid.py @@ -1,194 +1,5 @@ -from dataclasses import dataclass, field +"""Re-export of :class:`loop_common.geometry.PropertyGrid` (kept for the old import path).""" -import numpy as np +from loop_common.geometry import PropertyGrid, StructuredGrid -from LoopStructural.utils import getLogger - -logger = getLogger(__name__) - - -@dataclass -class StructuredGrid: - """A structured grid for storing 3D geological data. - - This class represents a regular 3D grid with properties and cell properties - that can be used for geological modelling and visualisation. - - Parameters - ---------- - origin : np.ndarray, optional - Origin point of the grid, by default [0, 0, 0] - step_vector : np.ndarray, optional - Step size in each direction, by default [1, 1, 1] - nsteps : np.ndarray, optional - Number of steps in each direction, by default [10, 10, 10] - cell_properties : Dict[str, np.ndarray], optional - Properties defined at cell centres, by default empty dict - properties : Dict[str, np.ndarray], optional - Properties defined at grid nodes, by default empty dict - name : str, optional - Name of the grid, by default "default_grid" - """ - - origin: np.ndarray = field(default_factory=lambda: np.array([0, 0, 0])) - step_vector: np.ndarray = field(default_factory=lambda: np.array([1, 1, 1])) - nsteps: np.ndarray = field(default_factory=lambda: np.array([10, 10, 10])) - cell_properties: dict[str, np.ndarray] = field(default_factory=dict) - properties: dict[str, np.ndarray] = field(default_factory=dict) - name: str = "default_grid" - - def to_dict(self): - """Convert the structured grid to a dictionary representation. - - Returns - ------- - dict - Dictionary containing all grid properties and metadata - """ - return { - "origin": self.origin, - "maximum": self.maximum, - "step_vector": self.step_vector, - "nsteps": self.nsteps, - "cell_properties": self.cell_properties, - "properties": self.properties, - "name": self.name, - } - - @property - def maximum(self): - """Calculate the maximum coordinates of the grid. - - Returns - ------- - np.ndarray - Maximum coordinates (origin + (nsteps - 1) * step_vector) - """ - return self.origin + (self.nsteps - 1) * self.step_vector - - def vtk(self): - """Convert the structured grid to a PyVista RectilinearGrid. - - Returns - ------- - pv.RectilinearGrid - PyVista grid object with all properties attached - - Raises - ------ - ImportError - If PyVista is not installed - """ - try: - import pyvista as pv - except ImportError: - raise ImportError("pyvista is required for vtk support") - x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0]) - y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1]) - z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2]) - grid = pv.RectilinearGrid( - x, - y, - z, - ) - for name, data in self.properties.items(): - grid[name] = data.reshape((grid.n_points, -1), order="F") - for name, data in self.cell_properties.items(): - grid.cell_data[name] = data.reshape((grid.n_cells, -1), order="F") - return grid - - def plot(self, pyvista_kwargs=None): - """Calls pyvista plot on the vtk object - - Parameters - ---------- - pyvista_kwargs : dict, optional - kwargs passed to pyvista.DataSet.plot(), by default {} - """ - if pyvista_kwargs is None: - pyvista_kwargs = {} - try: - self.vtk().plot(**pyvista_kwargs) - return - except ImportError: - logger.error("pyvista is required for vtk") - - @property - def cell_centres(self): - """Calculate the coordinates of cell centres. - - Returns - ------- - tuple of np.ndarray - X, Y, Z coordinates of all cell centres - """ - x = np.linspace( - self.origin[0] + self.step_vector[0] * 0.5, - self.maximum[0] + self.step_vector[0] * 0.5, - self.nsteps[0] - 1, - ) - y = np.linspace( - self.origin[1] + self.step_vector[1] * 0.5, - self.maximum[1] - self.step_vector[1] * 0.5, - self.nsteps[1] - 1, - ) - z = np.linspace( - self.origin[2] + self.step_vector[2] * 0.5, - self.maximum[2] - self.step_vector[2] * 0.5, - self.nsteps[2] - 1, - ) - x, y, z = np.meshgrid(x, y, z, indexing="ij") - return np.vstack([x.flatten(order="f"), y.flatten(order="f"), z.flatten(order="f")]).T - - @property - def nodes(self): - - x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0]) - y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1]) - z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2]) - x, y, z = np.meshgrid(x, y, z, indexing="ij") - return np.vstack([x.flatten(order="f"), y.flatten(order="f"), z.flatten(order="f")]).T - - def merge(self, other): - if not np.all(np.isclose(self.origin, other.origin)): - raise ValueError("Origin of grids must be the same") - if not np.all(np.isclose(self.step_vector, other.step_vector)): - raise ValueError("Step vector of grids must be the same") - if not np.all(np.isclose(self.nsteps, other.nsteps)): - raise ValueError("Number of steps of grids must be the same") - - for name, data in other.cell_properties.items(): - self.cell_properties[name] = data - for name, data in other.properties.items(): - self.properties[name] = data - - def save(self, filename, *, group="Loop"): - filename = str(filename) - ext = filename.split(".")[-1].lower() - if ext == "json": - import json - - with open(filename, "w") as f: - json.dump(self.to_dict(), f) - elif ext == "vtk": - self.vtk().save(filename) - - elif ext == "geoh5": - from LoopStructural.export.geoh5 import add_structured_grid_to_geoh5 - - add_structured_grid_to_geoh5(filename, self, groupname=group) - elif ext == "pkl": - import pickle - - with open(filename, "wb") as f: - pickle.dump(self, f) - elif ext == "omf": - from LoopStructural.export.omf_wrapper import add_structured_grid_to_omf - - add_structured_grid_to_omf(self, filename) - elif ext == "vo" or ext == "gocad": - from LoopStructural.export.gocad import _write_structured_grid_gocad - - _write_structured_grid_gocad(self, filename) - else: - raise ValueError(f"Unknown file extension {ext}") +__all__ = ["PropertyGrid", "StructuredGrid"] diff --git a/LoopStructural/modelling/core/_model_exporter.py b/LoopStructural/modelling/core/_model_exporter.py index c63305642..8276930e4 100644 --- a/LoopStructural/modelling/core/_model_exporter.py +++ b/LoopStructural/modelling/core/_model_exporter.py @@ -13,7 +13,7 @@ import pathlib from typing import TYPE_CHECKING -from ...geometry import StructuredGrid +from ...geometry import PropertyGrid from ...utils import getLogger, surface_list if TYPE_CHECKING: @@ -70,11 +70,11 @@ def get_stratigraphic_surfaces( @staticmethod def get_block_model( model: GeologicalModel, name: str = 'block model' - ) -> tuple[StructuredGrid, list]: + ) -> tuple[PropertyGrid, list]: # NOTE: bounding_box.structured_grid() returns loop_common's # interpolation-support StructuredGrid (no properties dict); use - # LoopStructural's own geometry StructuredGrid for storing values. - grid = StructuredGrid( + # the PropertyGrid result container for storing values. + grid = PropertyGrid( origin=model.bounding_box.origin, step_vector=model.bounding_box.step_vector, nsteps=model.bounding_box.nsteps, diff --git a/LoopStructural/modelling/features/_base_geological_feature.py b/LoopStructural/modelling/features/_base_geological_feature.py index 9d9dfbdea..6e8bfdd19 100644 --- a/LoopStructural/modelling/features/_base_geological_feature.py +++ b/LoopStructural/modelling/features/_base_geological_feature.py @@ -4,7 +4,7 @@ import numpy as np -from LoopStructural.geometry import StructuredGrid, VectorPoints +from LoopStructural.geometry import PropertyGrid, VectorPoints from LoopStructural.modelling.features import FeatureType from LoopStructural.utils import LoopIsosurfacer, LoopValueError, getLogger, surface_list from LoopStructural.utils.typing import NumericInput @@ -386,8 +386,8 @@ def scalar_field(self, bounding_box=None): bounding_box = self.model.bounding_box # NOTE: bounding_box.structured_grid() returns loop_common's # interpolation-support StructuredGrid (no properties dict); use - # LoopStructural's own geometry StructuredGrid for storing values. - grid = StructuredGrid( + # the PropertyGrid result container for storing values. + grid = PropertyGrid( origin=bounding_box.origin, step_vector=bounding_box.step_vector, nsteps=bounding_box.nsteps, @@ -417,7 +417,7 @@ def gradient_norm_scalar_field(self, bounding_box=None): if self.model is None: raise ValueError("Must specify bounding box") bounding_box = self.model.bounding_box - grid = StructuredGrid( + grid = PropertyGrid( origin=bounding_box.origin, step_vector=bounding_box.step_vector, nsteps=bounding_box.nsteps, diff --git a/packages/loop_common/src/loop_common/geometry/__init__.py b/packages/loop_common/src/loop_common/geometry/__init__.py index e2b3af537..4e47b8308 100644 --- a/packages/loop_common/src/loop_common/geometry/__init__.py +++ b/packages/loop_common/src/loop_common/geometry/__init__.py @@ -1,6 +1,6 @@ from ._bounding_box import BoundingBox from ._point import ValuePoints, VectorPoints -from ._structured_grid import StructuredGrid +from ._structured_grid import PropertyGrid, StructuredGrid from ._structured_grid_2d import StructuredGrid2DGeometry from ._structured_grid_3d import StructuredGrid3DGeometry from ._surface import Surface @@ -8,6 +8,7 @@ __all__ = [ "BoundingBox", + "PropertyGrid", "StructuredGrid", "StructuredGrid2DGeometry", "StructuredGrid3DGeometry", diff --git a/packages/loop_common/src/loop_common/geometry/_structured_grid.py b/packages/loop_common/src/loop_common/geometry/_structured_grid.py index d788788f8..06a7e8d4e 100644 --- a/packages/loop_common/src/loop_common/geometry/_structured_grid.py +++ b/packages/loop_common/src/loop_common/geometry/_structured_grid.py @@ -8,8 +8,34 @@ @dataclass -class StructuredGrid: - """A structured grid for storing 3D geological data.""" +class PropertyGrid: + """A regular 3D grid for storing property values (the result container). + + This is a plain data container, not an interpolation support: it holds + node-based ``properties`` and ``cell_properties`` on a regular grid and can + export them (``vtk``, ``save``). For the class used to interpolate on a + grid see :class:`loop_common.supports.StructuredGrid`. + + It was previously named ``StructuredGrid``; that name remains as an alias. + + This class represents a regular 3D grid with properties and cell properties + that can be used for geological modelling and visualisation. + + Parameters + ---------- + origin : np.ndarray, optional + Origin point of the grid, by default [0, 0, 0] + step_vector : np.ndarray, optional + Step size in each direction, by default [1, 1, 1] + nsteps : np.ndarray, optional + Number of steps in each direction, by default [10, 10, 10] + cell_properties : Dict[str, np.ndarray], optional + Properties defined at cell centres, by default empty dict + properties : Dict[str, np.ndarray], optional + Properties defined at grid nodes, by default empty dict + name : str, optional + Name of the grid, by default "default_grid" + """ origin: np.ndarray = field(default_factory=lambda: np.array([0, 0, 0])) step_vector: np.ndarray = field(default_factory=lambda: np.array([1, 1, 1])) @@ -19,6 +45,13 @@ class StructuredGrid: name: str = "default_grid" def to_dict(self): + """Convert the structured grid to a dictionary representation. + + Returns + ------- + dict + Dictionary containing all grid properties and metadata + """ return { "origin": self.origin, "maximum": self.maximum, @@ -31,17 +64,40 @@ def to_dict(self): @property def maximum(self): + """Calculate the maximum coordinates of the grid. + + Returns + ------- + np.ndarray + Maximum coordinates (origin + (nsteps - 1) * step_vector) + """ return self.origin + (self.nsteps - 1) * self.step_vector def vtk(self): + """Convert the structured grid to a PyVista RectilinearGrid. + + Returns + ------- + pv.RectilinearGrid + PyVista grid object with all properties attached + + Raises + ------ + ImportError + If PyVista is not installed + """ try: import pyvista as pv - except ImportError as exc: - raise ImportError("pyvista is required for vtk support") from exc + except ImportError: + raise ImportError("pyvista is required for vtk support") x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0]) y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1]) z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2]) - grid = pv.RectilinearGrid(x, y, z) + grid = pv.RectilinearGrid( + x, + y, + z, + ) for name, data in self.properties.items(): grid[name] = data.reshape((grid.n_points, -1), order="F") for name, data in self.cell_properties.items(): @@ -49,6 +105,13 @@ def vtk(self): return grid def plot(self, pyvista_kwargs=None): + """Calls pyvista plot on the vtk object + + Parameters + ---------- + pyvista_kwargs : dict, optional + kwargs passed to pyvista.DataSet.plot(), by default {} + """ if pyvista_kwargs is None: pyvista_kwargs = {} try: @@ -59,6 +122,13 @@ def plot(self, pyvista_kwargs=None): @property def cell_centres(self): + """Calculate the coordinates of cell centres. + + Returns + ------- + tuple of np.ndarray + X, Y, Z coordinates of all cell centres + """ x = np.linspace( self.origin[0] + self.step_vector[0] * 0.5, self.maximum[0] + self.step_vector[0] * 0.5, @@ -79,6 +149,7 @@ def cell_centres(self): @property def nodes(self): + x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0]) y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1]) z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2]) @@ -92,6 +163,7 @@ def merge(self, other): raise ValueError("Step vector of grids must be the same") if not np.all(np.isclose(self.nsteps, other.nsteps)): raise ValueError("Number of steps of grids must be the same") + for name, data in other.cell_properties.items(): self.cell_properties[name] = data for name, data in other.properties.items(): @@ -102,13 +174,32 @@ def save(self, filename, *, group="Loop"): ext = filename.split(".")[-1].lower() if ext == "json": import json + with open(filename, "w") as f: json.dump(self.to_dict(), f) elif ext == "vtk": self.vtk().save(filename) + + elif ext == "geoh5": + from LoopStructural.export.geoh5 import add_structured_grid_to_geoh5 + + add_structured_grid_to_geoh5(filename, self, groupname=group) elif ext == "pkl": import pickle + with open(filename, "wb") as f: pickle.dump(self, f) + elif ext == "omf": + from LoopStructural.export.omf_wrapper import add_structured_grid_to_omf + + add_structured_grid_to_omf(self, filename) + elif ext == "vo" or ext == "gocad": + from LoopStructural.export.gocad import _write_structured_grid_gocad + + _write_structured_grid_gocad(self, filename) else: raise ValueError(f"Unknown file extension {ext}") + + +# Backwards-compatible name (1.x); also keeps old pickles resolvable. +StructuredGrid = PropertyGrid From 86bb5bde82200ba48a97ad069dcf06546db21ace Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Fri, 30 Oct 2026 14:53:44 +1030 Subject: [PATCH 2/2] fix: require loop-common with PropertyGrid support --- COMPAT.md | 2 +- packages/loop_common/pyproject.toml | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/COMPAT.md b/COMPAT.md index 206406338..0de642d43 100644 --- a/COMPAT.md +++ b/COMPAT.md @@ -37,7 +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 | -| `LoopStructural.geometry.StructuredGrid` / `loop_common.geometry.StructuredGrid` (results container with `properties`/`cell_properties`) | `PropertyGrid` (same module; `StructuredGrid` stays as an alias). `LoopStructural.geometry` now re-exports the `loop_common` class instead of carrying a duplicate copy | 2026-10-29 | +| `LoopStructural.geometry.StructuredGrid` / `loop_common.geometry.StructuredGrid` (results container with `properties`/`cell_properties`) | `PropertyGrid` (same module; `StructuredGrid` stays as an alias). `LoopStructural.geometry` now re-exports the `loop_common` class instead of carrying a duplicate copy. Requires `loop-common>=0.1.1,<0.2.0`; publish the updated shared package before releasing LoopStructural | 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 | diff --git a/packages/loop_common/pyproject.toml b/packages/loop_common/pyproject.toml index 97ae8cc0b..f496bfefc 100644 --- a/packages/loop_common/pyproject.toml +++ b/packages/loop_common/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "loop-common" description = "Common utilities for LoopStructural" -version = "0.1.0" +version = "0.1.1" requires-python = ">=3.9" authors = [{ name = "Lachlan Grose", email = "lachlan.grose@monash.edu" }] readme = "README.md" @@ -30,4 +30,4 @@ tests = ["pytest"] [tool.setuptools.packages.find] where = ["src"] -include = ["loop_common", "loop_common.*"] \ No newline at end of file +include = ["loop_common", "loop_common.*"] diff --git a/pyproject.toml b/pyproject.toml index 3b2fe374e..a6f8c1884 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ 'Programming Language :: Python :: 3.12', ] dependencies = [ - "loop-common>=0.1.0,<0.2.0", + "loop-common>=0.1.1,<0.2.0", "loop-interpolation>=0.2.0,<0.3.0", "numpy>=1.18", "pandas", diff --git a/uv.lock b/uv.lock index a3d138fe9..93b5958ac 100644 --- a/uv.lock +++ b/uv.lock @@ -3167,7 +3167,7 @@ wheels = [ [[package]] name = "loop-common" -version = "0.1.0" +version = "0.1.1" source = { editable = "packages/loop_common" } dependencies = [ { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },