geometallurgy 0.4.11__py3-none-any.whl → 0.4.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. elphick/geomet/__init__.py +11 -11
  2. elphick/geomet/base.py +1133 -1133
  3. elphick/geomet/block_model.py +319 -358
  4. elphick/geomet/config/__init__.py +1 -1
  5. elphick/geomet/config/config_read.py +39 -39
  6. elphick/geomet/config/flowsheet_example_partition.yaml +31 -31
  7. elphick/geomet/config/flowsheet_example_simple.yaml +25 -25
  8. elphick/geomet/config/mc_config.yml +35 -35
  9. elphick/geomet/data/downloader.py +39 -39
  10. elphick/geomet/data/register.csv +12 -12
  11. elphick/geomet/datasets/__init__.py +2 -2
  12. elphick/geomet/datasets/datasets.py +47 -47
  13. elphick/geomet/datasets/downloader.py +40 -40
  14. elphick/geomet/datasets/register.csv +12 -12
  15. elphick/geomet/datasets/sample_data.py +196 -196
  16. elphick/geomet/extras.py +35 -35
  17. elphick/geomet/flowsheet/__init__.py +1 -1
  18. elphick/geomet/flowsheet/flowsheet.py +1216 -1193
  19. elphick/geomet/flowsheet/loader.py +99 -99
  20. elphick/geomet/flowsheet/operation.py +256 -256
  21. elphick/geomet/flowsheet/stream.py +39 -38
  22. elphick/geomet/interval_sample.py +641 -641
  23. elphick/geomet/io.py +379 -379
  24. elphick/geomet/plot.py +147 -147
  25. elphick/geomet/sample.py +28 -28
  26. elphick/geomet/utils/amenability.py +49 -49
  27. elphick/geomet/utils/block_model_converter.py +93 -93
  28. elphick/geomet/utils/components.py +136 -136
  29. elphick/geomet/utils/data.py +49 -49
  30. elphick/geomet/utils/estimates.py +108 -108
  31. elphick/geomet/utils/interp.py +193 -193
  32. elphick/geomet/utils/interp2.py +134 -134
  33. elphick/geomet/utils/layout.py +72 -72
  34. elphick/geomet/utils/moisture.py +61 -61
  35. elphick/geomet/utils/output.html +617 -0
  36. elphick/geomet/utils/pandas.py +378 -378
  37. elphick/geomet/utils/parallel.py +29 -29
  38. elphick/geomet/utils/partition.py +63 -63
  39. elphick/geomet/utils/size.py +51 -51
  40. elphick/geomet/utils/timer.py +80 -80
  41. elphick/geomet/utils/viz.py +56 -56
  42. elphick/geomet/validate.py.hide +176 -176
  43. {geometallurgy-0.4.11.dist-info → geometallurgy-0.4.13.dist-info}/LICENSE +21 -21
  44. {geometallurgy-0.4.11.dist-info → geometallurgy-0.4.13.dist-info}/METADATA +7 -5
  45. geometallurgy-0.4.13.dist-info/RECORD +49 -0
  46. {geometallurgy-0.4.11.dist-info → geometallurgy-0.4.13.dist-info}/WHEEL +1 -1
  47. elphick/geomet/utils/sampling.py +0 -5
  48. geometallurgy-0.4.11.dist-info/RECORD +0 -49
  49. {geometallurgy-0.4.11.dist-info → geometallurgy-0.4.13.dist-info}/entry_points.txt +0 -0
@@ -1,358 +1,319 @@
1
- import logging
2
- from functools import wraps
3
- from pathlib import Path
4
- from typing import Optional, Union, Literal, TYPE_CHECKING
5
-
6
- import numpy as np
7
- import pandas as pd
8
- from scipy import stats
9
-
10
- from elphick.geomet import extras
11
- from elphick.geomet.base import MassComposition
12
- from elphick.geomet.extras import BlockmodelExtras
13
- from elphick.geomet.utils.block_model_converter import volume_to_vtk
14
- from elphick.geomet.utils.timer import log_timer
15
-
16
- if TYPE_CHECKING:
17
- import pyvista as pv
18
-
19
-
20
- # Modify the import_extras decorator
21
- def import_extras(func):
22
- @wraps(func)
23
- def wrapper(*args, **kwargs):
24
- omf, omfvista, pv = extras.import_blockmodel_packages()
25
- extras_instance = BlockmodelExtras(omf, omfvista, pv)
26
- return func(*args, imports=extras_instance, **kwargs)
27
-
28
- return wrapper
29
-
30
-
31
- class BlockModel(MassComposition):
32
- def __init__(self,
33
- data: Optional[pd.DataFrame] = None,
34
- name: Optional[str] = None,
35
- moisture_in_scope: bool = True,
36
- mass_wet_var: Optional[str] = None,
37
- mass_dry_var: Optional[str] = None,
38
- moisture_var: Optional[str] = None,
39
- component_vars: Optional[list[str]] = None,
40
- composition_units: Literal['%', 'ppm', 'ppb'] = '%',
41
- components_as_symbols: bool = True,
42
- ranges: Optional[dict[str, list]] = None,
43
- config_file: Optional[Path] = None):
44
-
45
- if data is not None:
46
- if isinstance(data.index, pd.MultiIndex):
47
- if all([n.lower() in data.index.names for n in ['x', 'y', 'z', 'dx', 'dy', 'dz']]):
48
- self.is_irregular = True
49
- elif all([n.lower() in data.index.names for n in ['x', 'y', 'z']]):
50
- self.is_irregular = False
51
- data.index.set_names([n.lower() for n in data.index.names], inplace=True)
52
-
53
- else:
54
- raise ValueError("The index must be a pd.MultiIndex with names ['x', 'y', 'z'] "
55
- "or [['x', 'y', 'z', 'dx', 'dy', 'dz'].")
56
-
57
- # sort the data to ensure consistent with pyvista
58
- data.sort_index(level=['z', 'y', 'x'], ascending=[True, True, True], inplace=True)
59
-
60
- super().__init__(data=data, name=name, moisture_in_scope=moisture_in_scope,
61
- mass_wet_var=mass_wet_var, mass_dry_var=mass_dry_var,
62
- moisture_var=moisture_var, component_vars=component_vars,
63
- composition_units=composition_units, components_as_symbols=components_as_symbols,
64
- ranges=ranges, config_file=config_file)
65
-
66
- @classmethod
67
- @import_extras
68
- def from_omf(cls, omf_filepath: Path, imports,
69
- name: Optional[str] = None,
70
- columns: Optional[list[str]] = None) -> 'BlockModel':
71
-
72
- reader = imports.omf.OMFReader(str(omf_filepath))
73
- project: imports.omf.Project = reader.get_project()
74
- # get the first block model detected in the omf project
75
- block_model_candidates = [obj for obj in project.elements if isinstance(obj, imports.omf.volume.VolumeElement)]
76
- if name:
77
- omf_bm = [obj for obj in block_model_candidates if obj.name == name]
78
- if len(omf_bm) == 0:
79
- raise ValueError(f"No block model named '{name}' found in the OMF file.")
80
- else:
81
- omf_bm = omf_bm[0]
82
- elif len(block_model_candidates) > 1:
83
- names: list[str] = [obj.name for obj in block_model_candidates]
84
- raise ValueError(f"Multiple block models detected in the OMF file - provide a name argument from: {names}")
85
- else:
86
- omf_bm = block_model_candidates[0]
87
-
88
- origin = np.array(project.origin)
89
- bm = volume_to_vtk(omf_bm, origin=origin, columns=columns)
90
-
91
- # Create DataFrame
92
- df = pd.DataFrame(bm.cell_centers().points, columns=['x', 'y', 'z'])
93
-
94
- # set the index to the cell centroids
95
- df.set_index(['x', 'y', 'z'], drop=True, inplace=True)
96
-
97
- if not isinstance(bm, imports.pv.RectilinearGrid):
98
- for d, t in zip(['dx', 'dy', 'dz'], ['tensor_u', 'tensor_v', 'tensor_w']):
99
- # todo: fix - wrong shape
100
- df[d] = eval(f"omf_bm.geometry.{t}")
101
- df.set_index(['dx', 'dy', 'dz'], append=True, inplace=True)
102
-
103
- # Add the array data to the DataFrame
104
- for name in bm.array_names:
105
- df[name] = bm.get_array(name)
106
-
107
- # temporary workaround for no mass
108
- df['DMT'] = 2000
109
- moisture_in_scope = False
110
-
111
- return cls(data=df, name=omf_bm.name, moisture_in_scope=moisture_in_scope)
112
-
113
- @import_extras
114
- def to_omf(self, omf_filepath: Path, imports, name: str = 'Block Model', description: str = 'A block model'):
115
-
116
- # Create a Project instance
117
- project = imports.omf.Project(name=name, description=description)
118
-
119
- # Create a VolumeElement instance for the block model
120
- block_model = imports.omf.VolumeElement(name=name, description=description,
121
- geometry=imports.omf.VolumeGridGeometry())
122
-
123
- # Set the geometry of the block model
124
- block_model.geometry.origin = self.data.index.get_level_values('x').min(), \
125
- self.data.index.get_level_values('y').min(), \
126
- self.data.index.get_level_values('z').min()
127
-
128
- # Set the axis directions
129
- block_model.geometry.axis_u = [1, 0, 0] # Set the u-axis to point along the x-axis
130
- block_model.geometry.axis_v = [0, 1, 0] # Set the v-axis to point along the y-axis
131
- block_model.geometry.axis_w = [0, 0, 1] # Set the w-axis to point along the z-axis
132
-
133
- # Set the tensor locations and dimensions
134
- if 'dx' not in self.data.index.names:
135
- # Calculate the dimensions of the cells
136
- x_dims = np.diff(self.data.index.get_level_values('x').unique())
137
- y_dims = np.diff(self.data.index.get_level_values('y').unique())
138
- z_dims = np.diff(self.data.index.get_level_values('z').unique())
139
-
140
- # Append an extra value to the end of the dimensions arrays
141
- x_dims = np.append(x_dims, x_dims[-1])
142
- y_dims = np.append(y_dims, y_dims[-1])
143
- z_dims = np.append(z_dims, z_dims[-1])
144
-
145
- # Assign the dimensions to the tensor attributes
146
- block_model.geometry.tensor_u = x_dims
147
- block_model.geometry.tensor_v = y_dims
148
- block_model.geometry.tensor_w = z_dims
149
- else:
150
- block_model.geometry.tensor_u = self.data.index.get_level_values('dx').unique().tolist()
151
- block_model.geometry.tensor_v = self.data.index.get_level_values('dy').unique().tolist()
152
- block_model.geometry.tensor_w = self.data.index.get_level_values('dz').unique().tolist()
153
-
154
- # Sort the blocks by their x, y, and z coordinates
155
- blocks: pd.DataFrame = self.data.sort_index()
156
-
157
- # Add the data to the block model
158
- data = [imports.omf.ScalarData(name=col, location='cells', array=blocks[col].values) for col in blocks.columns]
159
- block_model.data = data
160
-
161
- # Add the block model to the project
162
- project.elements = [block_model]
163
-
164
- assert project.validate()
165
-
166
- # Write the project to a file
167
- imports.omf.OMFWriter(project, str(omf_filepath))
168
-
169
- @log_timer
170
- def get_blocks(self) -> Union['pv.StructuredGrid', 'pv.UnstructuredGrid']:
171
-
172
- try:
173
- # Attempt to create a regular grid
174
- grid = self.create_structured_grid()
175
- self._logger.debug("Created a pv.StructuredGrid.")
176
- except ValueError:
177
- # If it fails, create an irregular grid
178
- grid = self.create_unstructured_grid()
179
- self._logger.debug("Created a pv.UnstructuredGrid.")
180
- return grid
181
-
182
- @import_extras
183
- def plot(self, scalar: str, imports, show_edges: bool = True) -> 'pv.Plotter':
184
-
185
- if scalar not in self.data_columns:
186
- raise ValueError(f"Column '{scalar}' not found in the DataFrame.")
187
-
188
- # Create a PyVista plotter
189
- plotter = imports.pv.Plotter()
190
-
191
- mesh = self.get_blocks()
192
-
193
- # Add a thresholded mesh to the plotter
194
- plotter.add_mesh_threshold(mesh, scalars=scalar, show_edges=show_edges)
195
-
196
- return plotter
197
-
198
- def is_regular(self) -> bool:
199
- """
200
- Determine if the grid spacing is complete and regular
201
- If it is, a pv.StructuredGrid is suitable.
202
- If not, a pv.UnstructuredGrid is suitable.
203
-
204
- :return:
205
- """
206
-
207
- block_sizes = np.array(self._block_sizes())
208
- return np.all(np.isclose(np.mean(block_sizes, axis=1), 0))
209
-
210
- def _block_sizes(self):
211
- data = self.data
212
- x_unique = data.index.get_level_values('x').unique()
213
- y_unique = data.index.get_level_values('y').unique()
214
- z_unique = data.index.get_level_values('z').unique()
215
-
216
- x_spacing = np.diff(x_unique)
217
- y_spacing = np.diff(y_unique)
218
- z_spacing = np.diff(z_unique)
219
-
220
- return x_spacing, y_spacing, z_spacing
221
-
222
- def common_block_size(self):
223
- data = self.data
224
- x_unique = data.index.get_level_values('x').unique()
225
- y_unique = data.index.get_level_values('y').unique()
226
- z_unique = data.index.get_level_values('z').unique()
227
-
228
- x_spacing = np.abs(np.diff(x_unique))
229
- y_spacing = np.abs(np.diff(y_unique))
230
- z_spacing = np.abs(np.diff(z_unique))
231
-
232
- return stats.mode(x_spacing).mode, stats.mode(y_spacing).mode, stats.mode(z_spacing).mode
233
-
234
- @import_extras
235
- def create_structured_grid(self, imports) -> 'pv.StructuredGrid':
236
-
237
- # Get the unique x, y, z coordinates (centroids)
238
- data = self.data
239
- x_centroids = data.index.get_level_values('x').unique()
240
- y_centroids = data.index.get_level_values('y').unique()
241
- z_centroids = data.index.get_level_values('z').unique()
242
-
243
- # Calculate the cell size (assuming all cells are of equal size)
244
- dx = np.diff(x_centroids)[0]
245
- dy = np.diff(y_centroids)[0]
246
- dz = np.diff(z_centroids)[0]
247
-
248
- # Calculate the grid points
249
- x_points = np.concatenate([x_centroids - dx / 2, x_centroids[-1:] + dx / 2])
250
- y_points = np.concatenate([y_centroids - dy / 2, y_centroids[-1:] + dy / 2])
251
- z_points = np.concatenate([z_centroids - dz / 2, z_centroids[-1:] + dz / 2])
252
-
253
- # Create the 3D grid of points
254
- x, y, z = np.meshgrid(x_points, y_points, z_points, indexing='ij')
255
-
256
- # Create a StructuredGrid object
257
- grid = imports.pv.StructuredGrid(x, y, z)
258
-
259
- # Add the data from the DataFrame to the grid
260
- for column in data.columns:
261
- grid.cell_data[column] = data[column].values
262
-
263
- return grid
264
-
265
- def create_voxels(self) -> 'pv.UnstructuredGrid':
266
- grid = self.voxelise(self.data)
267
- return grid
268
-
269
- @import_extras
270
- def create_unstructured_grid(self, imports) -> 'pv.UnstructuredGrid':
271
- """
272
- Requires the index to be a pd.MultiIndex with names ['x', 'y', 'z', 'dx', 'dy', 'dz'].
273
- :return:
274
- """
275
-
276
- # Get the x, y, z coordinates and cell dimensions
277
- blocks = self.data.reset_index().sort_values(['z', 'y', 'x'])
278
- # if no dims are passed, estimate them
279
- if 'dx' not in blocks.columns:
280
- dx, dy, dz = self.common_block_size()
281
- blocks['dx'] = dx
282
- blocks['dy'] = dy
283
- blocks['dz'] = dz
284
-
285
- x, y, z, dx, dy, dz = (blocks[col].values for col in blocks.columns if col in ['x', 'y', 'z', 'dx', 'dy', 'dz'])
286
- blocks.set_index(['x', 'y', 'z', 'dx', 'dy', 'dz'], inplace=True)
287
- # Create the cell points/vertices
288
- # REF: https://github.com/OpenGeoVis/PVGeo/blob/main/PVGeo/filters/voxelize.py
289
-
290
- n_cells = len(x)
291
-
292
- # Generate cell nodes for all points in data set
293
- # - Bottom
294
- c_n1 = np.stack(((x - dx / 2), (y - dy / 2), (z - dz / 2)), axis=1)
295
- c_n2 = np.stack(((x + dx / 2), (y - dy / 2), (z - dz / 2)), axis=1)
296
- c_n3 = np.stack(((x - dx / 2), (y + dy / 2), (z - dz / 2)), axis=1)
297
- c_n4 = np.stack(((x + dx / 2), (y + dy / 2), (z - dz / 2)), axis=1)
298
- # - Top
299
- c_n5 = np.stack(((x - dx / 2), (y - dy / 2), (z + dz / 2)), axis=1)
300
- c_n6 = np.stack(((x + dx / 2), (y - dy / 2), (z + dz / 2)), axis=1)
301
- c_n7 = np.stack(((x - dx / 2), (y + dy / 2), (z + dz / 2)), axis=1)
302
- c_n8 = np.stack(((x + dx / 2), (y + dy / 2), (z + dz / 2)), axis=1)
303
-
304
- # - Concatenate
305
- # nodes = np.concatenate((c_n1, c_n2, c_n3, c_n4, c_n5, c_n6, c_n7, c_n8), axis=0)
306
- nodes = np.hstack((c_n1, c_n2, c_n3, c_n4, c_n5, c_n6, c_n7, c_n8)).ravel().reshape(n_cells * 8, 3)
307
-
308
- # create the cells
309
- # REF: https://docs/pyvista.org/examples/00-load/create-unstructured-surface.html
310
- cells_hex = np.arange(n_cells * 8).reshape(n_cells, 8)
311
-
312
- grid = imports.pv.UnstructuredGrid({imports.pv.CellType.VOXEL: cells_hex}, nodes)
313
-
314
- # add the attributes (column) data
315
- for col in blocks.columns:
316
- grid.cell_data[col] = blocks[col].values
317
-
318
- return grid
319
-
320
- @staticmethod
321
- @log_timer
322
- @import_extras
323
- def voxelise(blocks, imports) -> 'pv.UnstructuredGrid':
324
-
325
- logger = logging.getLogger(__name__)
326
- msg = "Voxelising blocks requires PVGeo package."
327
- logger.error(msg)
328
- raise NotImplementedError(msg)
329
-
330
- # vtkpoints = PVGeo.points_to_poly_data(centroid_data)
331
-
332
- x_values = blocks.index.get_level_values('x').values
333
- y_values = blocks.index.get_level_values('y').values
334
- z_values = blocks.index.get_level_values('z').values
335
-
336
- # Stack x, y, z values into a numpy array
337
- centroids = np.column_stack((x_values, y_values, z_values))
338
-
339
- # Create a PolyData object
340
- polydata = imports.pv.PolyData(centroids)
341
-
342
- # Add cell values as point data
343
- for column in blocks.columns:
344
- polydata[column] = blocks[[column]]
345
-
346
- # Create a Voxelizer filter
347
- voxelizer = PVGeo.filters.VoxelizePoints()
348
- # Apply the filter to the points
349
- grid = voxelizer.apply(polydata)
350
-
351
- logger.info(f"Voxelised {blocks.shape[0]} points.")
352
- logger.info("Recovered Angle (deg.): %.3f" % voxelizer.get_angle())
353
- logger.info("Recovered Cell Sizes: (%.2f, %.2f, %.2f)" % voxelizer.get_spacing())
354
-
355
- return grid
356
-
357
- def __str__(self):
358
- return f"BlockModel: {self.name}\n{self.aggregate.to_dict()}"
1
+ import logging
2
+ from functools import wraps
3
+ from pathlib import Path
4
+ from typing import Optional, Union, Literal, TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from scipy import stats
9
+
10
+ from elphick.geomet import extras
11
+ from elphick.geomet.base import MassComposition
12
+ from elphick.geomet.extras import BlockmodelExtras
13
+ from elphick.geomet.utils.timer import log_timer
14
+
15
+ if TYPE_CHECKING:
16
+ import pyvista as pv
17
+
18
+
19
+ # Modify the import_extras decorator
20
+ def import_extras(func):
21
+ @wraps(func)
22
+ def wrapper(*args, **kwargs):
23
+ omfpandas, omfvista, pv = extras.import_blockmodel_packages()
24
+ extras_instance = BlockmodelExtras(omfpandas, omfvista, pv)
25
+ return func(*args, imports=extras_instance, **kwargs)
26
+
27
+ return wrapper
28
+
29
+
30
+ class BlockModel(MassComposition):
31
+ def __init__(self,
32
+ data: Optional[pd.DataFrame] = None,
33
+ name: Optional[str] = None,
34
+ moisture_in_scope: bool = True,
35
+ mass_wet_var: Optional[str] = None,
36
+ mass_dry_var: Optional[str] = None,
37
+ moisture_var: Optional[str] = None,
38
+ component_vars: Optional[list[str]] = None,
39
+ composition_units: Literal['%', 'ppm', 'ppb'] = '%',
40
+ components_as_symbols: bool = True,
41
+ ranges: Optional[dict[str, list]] = None,
42
+ config_file: Optional[Path] = None):
43
+
44
+ if data is not None:
45
+ if isinstance(data.index, pd.MultiIndex):
46
+ if all([n.lower() in data.index.names for n in ['x', 'y', 'z', 'dx', 'dy', 'dz']]):
47
+ self.is_irregular = True
48
+ elif all([n.lower() in data.index.names for n in ['x', 'y', 'z']]):
49
+ self.is_irregular = False
50
+ data.index.set_names([n.lower() for n in data.index.names], inplace=True)
51
+
52
+ else:
53
+ raise ValueError("The index must be a pd.MultiIndex with names ['x', 'y', 'z'] "
54
+ "or [['x', 'y', 'z', 'dx', 'dy', 'dz'].")
55
+
56
+ # sort the data to ensure consistent with pyvista
57
+ data.sort_index(level=['z', 'y', 'x'], ascending=[True, True, True], inplace=True)
58
+
59
+ super().__init__(data=data, name=name, moisture_in_scope=moisture_in_scope,
60
+ mass_wet_var=mass_wet_var, mass_dry_var=mass_dry_var,
61
+ moisture_var=moisture_var, component_vars=component_vars,
62
+ composition_units=composition_units, components_as_symbols=components_as_symbols,
63
+ ranges=ranges, config_file=config_file)
64
+
65
+ @classmethod
66
+ @import_extras
67
+ def from_omf(cls, omf_filepath: Path, imports,
68
+ element_name: Optional[str] = None,
69
+ columns: Optional[list[str]] = None,
70
+ query: Optional[str] = None,
71
+ density: float = 2.5) -> 'BlockModel':
72
+ """Create a BlockModel from an OMF file.
73
+
74
+ Args:
75
+ omf_filepath: Path to the OMF file.
76
+ imports: internally used to import the necessary packages.
77
+ element_name: The name of the element in the OMF file.
78
+ columns: The columns to extract from the OMF file.
79
+ query: The query to filter the DataFrame.
80
+ density: The density of the material in g/cm3, used to calculate DMT (Dry Mass Tonnes). A workaround.
81
+
82
+ Returns:
83
+ BlockModel: The BlockModel instance.
84
+
85
+ """
86
+
87
+ omfpr: imports.omfpandas.OMFPandasReader = imports.omfpandas.OMFPandasReader(filepath=omf_filepath)
88
+ blocks: pd.DataFrame = omfpr.read_blockmodel(blockmodel_name=element_name, attributes=columns,
89
+ query=query)
90
+
91
+ # get the block volume
92
+
93
+ volume: Union[float, np.ndarray[float]]
94
+ from omfpandas.blockmodel import OMFBlockModel
95
+ from omfpandas.blockmodels.convert_blockmodel import df_to_blockmodel
96
+ from omfpandas.blockmodels.geometry import Geometry
97
+ geom: Geometry = OMFBlockModel(df_to_blockmodel(blocks, blockmodel_name=element_name)).geometry
98
+
99
+ if geom.__class__.__name__ == 'RegularGeometry':
100
+ volume = geom.block_size[0] * geom.block_size[1] * geom.block_size[2]
101
+ elif geom.__class__.__name__ == 'TensorGeometry':
102
+ # TODO: Implement the volume calculation for TensorGeometry - this is a placeholder.
103
+ volume = geom.block_sizes[0][0] * geom.block_sizes[0][1] * geom.block_sizes[0][2]
104
+ else:
105
+ raise ValueError(f"Geometry type '{geom.__class__.__name__}' not supported.")
106
+
107
+ if density is not None:
108
+ blocks['mass_dry'] = volume * density
109
+ moisture_in_scope = False
110
+
111
+ return cls(data=blocks, name=element_name, mass_dry_var='mass_dry', moisture_in_scope=moisture_in_scope)
112
+
113
+ @import_extras
114
+ def to_omf(self, omf_filepath: Path, imports, element_name: str = 'Block Model',
115
+ description: str = 'A block model'):
116
+ """Write the BlockModel to an OMF file.
117
+
118
+ Args:
119
+ omf_filepath: Path to the OMF file.
120
+ imports: internally used to import the necessary packages.
121
+ element_name: The name of the element in the OMF file.
122
+ description: Description of the block model.
123
+ """
124
+ # Create an OMFPandasWriter instance
125
+ writer = imports.omfpandas.OMFPandasWriter(filepath=omf_filepath)
126
+
127
+ # Write the block model to the OMF file
128
+ writer.write_blockmodel(blockmodel_name=element_name, description=description, dataframe=self.data)
129
+
130
+ @log_timer
131
+ def get_blocks(self) -> Union['pv.StructuredGrid', 'pv.UnstructuredGrid']:
132
+
133
+ try:
134
+ # Attempt to create a regular grid
135
+ grid = self.create_structured_grid()
136
+ self._logger.debug("Created a pv.StructuredGrid.")
137
+ except ValueError:
138
+ # If it fails, create an irregular grid
139
+ grid = self.create_unstructured_grid()
140
+ self._logger.debug("Created a pv.UnstructuredGrid.")
141
+ return grid
142
+
143
+ @import_extras
144
+ def plot(self, scalar: str, imports, show_edges: bool = True) -> 'pv.Plotter':
145
+
146
+ if scalar not in self.data_columns:
147
+ raise ValueError(f"Column '{scalar}' not found in the DataFrame.")
148
+
149
+ # Create a PyVista plotter
150
+ plotter = imports.pv.Plotter()
151
+
152
+ mesh = self.get_blocks()
153
+
154
+ # Add a thresholded mesh to the plotter
155
+ plotter.add_mesh_threshold(mesh, scalars=scalar, show_edges=show_edges)
156
+
157
+ return plotter
158
+
159
+ def is_regular(self) -> bool:
160
+ """
161
+ Determine if the grid spacing is complete and regular
162
+ If it is, a pv.StructuredGrid is suitable.
163
+ If not, a pv.UnstructuredGrid is suitable.
164
+
165
+ :return:
166
+ """
167
+
168
+ block_sizes = np.array(self._block_sizes())
169
+ return np.all(np.isclose(np.mean(block_sizes, axis=1), 0))
170
+
171
+ def _block_sizes(self):
172
+ data = self.data
173
+ x_unique = data.index.get_level_values('x').unique()
174
+ y_unique = data.index.get_level_values('y').unique()
175
+ z_unique = data.index.get_level_values('z').unique()
176
+
177
+ x_spacing = np.diff(x_unique)
178
+ y_spacing = np.diff(y_unique)
179
+ z_spacing = np.diff(z_unique)
180
+
181
+ return x_spacing, y_spacing, z_spacing
182
+
183
+ def common_block_size(self):
184
+ data = self.data
185
+ x_unique = data.index.get_level_values('x').unique()
186
+ y_unique = data.index.get_level_values('y').unique()
187
+ z_unique = data.index.get_level_values('z').unique()
188
+
189
+ x_spacing = np.abs(np.diff(x_unique))
190
+ y_spacing = np.abs(np.diff(y_unique))
191
+ z_spacing = np.abs(np.diff(z_unique))
192
+
193
+ return stats.mode(x_spacing).mode, stats.mode(y_spacing).mode, stats.mode(z_spacing).mode
194
+
195
+ @import_extras
196
+ def create_structured_grid(self, imports) -> 'pv.StructuredGrid':
197
+
198
+ # Get the unique x, y, z coordinates (centroids)
199
+ data = self.data.sort_values(['z', 'y', 'x']) # ensure the data is sorted F-style
200
+ x_centroids = data.index.get_level_values('x').unique()
201
+ y_centroids = data.index.get_level_values('y').unique()
202
+ z_centroids = data.index.get_level_values('z').unique()
203
+
204
+ # Calculate the cell size (assuming all cells are of equal size)
205
+ dx = np.diff(x_centroids)[0]
206
+ dy = np.diff(y_centroids)[0]
207
+ dz = np.diff(z_centroids)[0]
208
+
209
+ # Calculate the grid points
210
+ x_points = np.concatenate([x_centroids - dx / 2, x_centroids[-1:] + dx / 2])
211
+ y_points = np.concatenate([y_centroids - dy / 2, y_centroids[-1:] + dy / 2])
212
+ z_points = np.concatenate([z_centroids - dz / 2, z_centroids[-1:] + dz / 2])
213
+
214
+ # Create the 3D grid of points
215
+ x, y, z = np.meshgrid(x_points, y_points, z_points, indexing='ij')
216
+
217
+ # Create a StructuredGrid object
218
+ grid = imports.pv.StructuredGrid(x, y, z)
219
+
220
+ # Add the data from the DataFrame to the grid
221
+ for column in data.columns:
222
+ grid.cell_data[column] = data[column].values
223
+
224
+ return grid
225
+
226
+ def create_voxels(self) -> 'pv.UnstructuredGrid':
227
+ grid = self.voxelise(self.data)
228
+ return grid
229
+
230
+ @import_extras
231
+ def create_unstructured_grid(self, imports) -> 'pv.UnstructuredGrid':
232
+ """
233
+ Requires the index to be a pd.MultiIndex with names ['x', 'y', 'z', 'dx', 'dy', 'dz'].
234
+ :return:
235
+ """
236
+
237
+ # Get the x, y, z coordinates and cell dimensions
238
+ blocks = self.data.reset_index().sort_values(['z', 'y', 'x']) # ensure the data is sorted F-style
239
+ # if no dims are passed, estimate them
240
+ if 'dx' not in blocks.columns:
241
+ dx, dy, dz = self.common_block_size()
242
+ blocks['dx'] = dx
243
+ blocks['dy'] = dy
244
+ blocks['dz'] = dz
245
+
246
+ x, y, z, dx, dy, dz = (blocks[col].values for col in blocks.columns if col in ['x', 'y', 'z', 'dx', 'dy', 'dz'])
247
+ blocks.set_index(['x', 'y', 'z', 'dx', 'dy', 'dz'], inplace=True)
248
+ # Create the cell points/vertices
249
+ # REF: https://github.com/OpenGeoVis/PVGeo/blob/main/PVGeo/filters/voxelize.py
250
+
251
+ n_cells = len(x)
252
+
253
+ # Generate cell nodes for all points in data set
254
+ # - Bottom
255
+ c_n1 = np.stack(((x - dx / 2), (y - dy / 2), (z - dz / 2)), axis=1)
256
+ c_n2 = np.stack(((x + dx / 2), (y - dy / 2), (z - dz / 2)), axis=1)
257
+ c_n3 = np.stack(((x - dx / 2), (y + dy / 2), (z - dz / 2)), axis=1)
258
+ c_n4 = np.stack(((x + dx / 2), (y + dy / 2), (z - dz / 2)), axis=1)
259
+ # - Top
260
+ c_n5 = np.stack(((x - dx / 2), (y - dy / 2), (z + dz / 2)), axis=1)
261
+ c_n6 = np.stack(((x + dx / 2), (y - dy / 2), (z + dz / 2)), axis=1)
262
+ c_n7 = np.stack(((x - dx / 2), (y + dy / 2), (z + dz / 2)), axis=1)
263
+ c_n8 = np.stack(((x + dx / 2), (y + dy / 2), (z + dz / 2)), axis=1)
264
+
265
+ # - Concatenate
266
+ # nodes = np.concatenate((c_n1, c_n2, c_n3, c_n4, c_n5, c_n6, c_n7, c_n8), axis=0)
267
+ nodes = np.hstack((c_n1, c_n2, c_n3, c_n4, c_n5, c_n6, c_n7, c_n8)).ravel().reshape(n_cells * 8, 3)
268
+
269
+ # create the cells
270
+ # REF: https://docs/pyvista.org/examples/00-load/create-unstructured-surface.html
271
+ cells_hex = np.arange(n_cells * 8).reshape(n_cells, 8)
272
+
273
+ grid = imports.pv.UnstructuredGrid({imports.pv.CellType.VOXEL: cells_hex}, nodes)
274
+
275
+ # add the attributes (column) data
276
+ for col in blocks.columns:
277
+ grid.cell_data[col] = blocks[col].values
278
+
279
+ return grid
280
+
281
+ @staticmethod
282
+ @log_timer
283
+ @import_extras
284
+ def voxelise(blocks, imports) -> 'pv.UnstructuredGrid':
285
+
286
+ logger = logging.getLogger(__name__)
287
+ msg = "Voxelising blocks requires PVGeo package."
288
+ logger.error(msg)
289
+ raise NotImplementedError(msg)
290
+
291
+ # vtkpoints = PVGeo.points_to_poly_data(centroid_data)
292
+
293
+ x_values = blocks.index.get_level_values('x').values
294
+ y_values = blocks.index.get_level_values('y').values
295
+ z_values = blocks.index.get_level_values('z').values
296
+
297
+ # Stack x, y, z values into a numpy array
298
+ centroids = np.column_stack((x_values, y_values, z_values))
299
+
300
+ # Create a PolyData object
301
+ polydata = imports.pv.PolyData(centroids)
302
+
303
+ # Add cell values as point data
304
+ for column in blocks.columns:
305
+ polydata[column] = blocks[[column]]
306
+
307
+ # Create a Voxelizer filter
308
+ voxelizer = PVGeo.filters.VoxelizePoints()
309
+ # Apply the filter to the points
310
+ grid = voxelizer.apply(polydata)
311
+
312
+ logger.info(f"Voxelised {blocks.shape[0]} points.")
313
+ logger.info("Recovered Angle (deg.): %.3f" % voxelizer.get_angle())
314
+ logger.info("Recovered Cell Sizes: (%.2f, %.2f, %.2f)" % voxelizer.get_spacing())
315
+
316
+ return grid
317
+
318
+ def __str__(self):
319
+ return f"BlockModel: {self.name}\n{self.aggregate.to_dict()}"