Skip to content

Commit 228ca78

Browse files
authored
Merge pull request #286 from Loop3D/export/gocad-vo
fix: adding gocad export for structured grid support
2 parents 06eeca4 + f507760 commit 228ca78

6 files changed

Lines changed: 288 additions & 40 deletions

File tree

.github/workflows/tester.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Installing dependencies
3434
shell: bash -l {0}
3535
run: |
36-
conda install -c conda-forge numpy scipy scikit-image scikit-learn pytest networkx osqp matplotlib -y
36+
conda install -c conda-forge numpy scipy scikit-image scikit-learn pyvista pandas pytest networkx osqp matplotlib -y
3737
- name: Building and install
3838
shell: bash -l {0}
3939
run: |

LoopStructural/datatypes/_structured_grid.py

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class StructuredGrid:
2828
name : str, optional
2929
Name of the grid, by default "default_grid"
3030
"""
31+
3132
origin: np.ndarray = field(default_factory=lambda: np.array([0, 0, 0]))
3233
step_vector: np.ndarray = field(default_factory=lambda: np.array([1, 1, 1]))
3334
nsteps: np.ndarray = field(default_factory=lambda: np.array([10, 10, 10]))
@@ -60,9 +61,9 @@ def maximum(self):
6061
Returns
6162
-------
6263
np.ndarray
63-
Maximum coordinates (origin + nsteps * step_vector)
64+
Maximum coordinates (origin + (nsteps - 1) * step_vector)
6465
"""
65-
return self.origin + self.nsteps * self.step_vector
66+
return self.origin + (self.nsteps-1) * self.step_vector
6667

6768
def vtk(self):
6869
"""Convert the structured grid to a PyVista RectilinearGrid.
@@ -134,15 +135,16 @@ def cell_centres(self):
134135
self.nsteps[2] - 1,
135136
)
136137
x, y, z = np.meshgrid(x, y, z, indexing="ij")
137-
return np.vstack([x.flatten(order='f'), y.flatten(order='f'), z.flatten(order='f')]).T
138+
return np.vstack([x.flatten(order="f"), y.flatten(order="f"), z.flatten(order="f")]).T
138139

139140
@property
140141
def nodes(self):
142+
141143
x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0])
142144
y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1])
143145
z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2])
144146
x, y, z = np.meshgrid(x, y, z, indexing="ij")
145-
return np.vstack([x.flatten(order='f'), y.flatten(order='f'), z.flatten(order='f')]).T
147+
return np.vstack([x.flatten(order="f"), y.flatten(order="f"), z.flatten(order="f")]).T
146148

147149
def merge(self, other):
148150
if not np.all(np.isclose(self.origin, other.origin)):
@@ -157,36 +159,33 @@ def merge(self, other):
157159
for name, data in other.properties.items():
158160
self.properties[name] = data
159161

160-
def save(self, filename, *,group='Loop'):
162+
def save(self, filename, *, group="Loop"):
161163
filename = str(filename)
162-
ext = filename.split('.')[-1]
163-
if ext == 'json':
164+
ext = filename.split(".")[-1].lower()
165+
if ext == "json":
164166
import json
165167

166-
with open(filename, 'w') as f:
168+
with open(filename, "w") as f:
167169
json.dump(self.to_dict(), f)
168-
elif ext == 'vtk':
170+
elif ext == "vtk":
169171
self.vtk().save(filename)
170172

171-
elif ext == 'geoh5':
173+
elif ext == "geoh5":
172174
from LoopStructural.export.geoh5 import add_structured_grid_to_geoh5
173175

174176
add_structured_grid_to_geoh5(filename, self, groupname=group)
175-
elif ext == 'pkl':
177+
elif ext == "pkl":
176178
import pickle
177179

178-
with open(filename, 'wb') as f:
180+
with open(filename, "wb") as f:
179181
pickle.dump(self, f)
180-
elif ext == 'omf':
182+
elif ext == "omf":
181183
from LoopStructural.export.omf_wrapper import add_structured_grid_to_omf
182184

183185
add_structured_grid_to_omf(self, filename)
184-
elif ext == 'vs':
185-
raise NotImplementedError(
186-
"Saving structured grids in gocad format is not yet implemented"
187-
)
188-
# from LoopStructural.export.gocad import _write_structued_grid
186+
elif ext == "vo" or ext == "gocad":
187+
from LoopStructural.export.gocad import _write_structured_grid_gocad
189188

190-
# _write_pointset(self, filename)
189+
_write_structured_grid_gocad(self, filename)
191190
else:
192-
raise ValueError(f'Unknown file extension {ext}')
191+
raise ValueError(f"Unknown file extension {ext}")

LoopStructural/export/gocad.py

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,153 @@
1+
import re
2+
from pathlib import Path
3+
14
import numpy as np
25

6+
from LoopStructural.utils import getLogger
7+
8+
logger = getLogger(__name__)
9+
10+
11+
def _normalise_voxet_property(values, property_name, nsteps):
12+
array = np.asarray(values)
13+
expected_shape = tuple(int(step) for step in nsteps)
14+
expected_size = int(np.prod(expected_shape))
15+
16+
if array.shape == expected_shape:
17+
flat_values = array.reshape(-1, order="F")
18+
else:
19+
flat_values = np.squeeze(array)
20+
if flat_values.shape == expected_shape:
21+
flat_values = flat_values.reshape(-1, order="F")
22+
elif flat_values.ndim == 1 and flat_values.size == expected_size:
23+
flat_values = flat_values
24+
else:
25+
raise ValueError(
26+
f"Property '{property_name}' must have shape {expected_shape} or size {expected_size}"
27+
)
28+
29+
if np.issubdtype(flat_values.dtype, np.integer):
30+
if flat_values.size == 0:
31+
export_dtype = np.int8
32+
storage_type = "Octet"
33+
element_size = 1
34+
elif flat_values.min() >= np.iinfo(np.int8).min and flat_values.max() <= np.iinfo(np.int8).max:
35+
export_dtype = np.int8
36+
storage_type = "Octet"
37+
element_size = 1
38+
else:
39+
export_dtype = np.dtype(">i4")
40+
storage_type = "Integer"
41+
element_size = 4
42+
no_data_value = None
43+
elif np.issubdtype(flat_values.dtype, np.floating):
44+
export_dtype = np.dtype(">f4")
45+
storage_type = "Float"
46+
element_size = 4
47+
no_data_value = -999999.0
48+
flat_values = np.nan_to_num(flat_values, nan=no_data_value)
49+
else:
50+
raise ValueError(f"Property '{property_name}' has unsupported dtype {flat_values.dtype}")
51+
52+
return {
53+
"values": np.asarray(flat_values, dtype=export_dtype),
54+
"storage_type": storage_type,
55+
"element_size": element_size,
56+
"no_data_value": no_data_value,
57+
}
58+
59+
60+
def _write_structured_grid_gocad(grid, file_name):
61+
"""Write a StructuredGrid to GOCAD VOXET format."""
62+
vo_path = Path(file_name).with_suffix(".vo")
63+
axis_n = np.asarray(grid.nsteps, dtype=int)
64+
property_source = grid.properties
65+
axis_min = np.min(grid.nodes, axis=0)
66+
axis_max = np.max(grid.nodes, axis=0)
67+
68+
if property_source:
69+
if grid.cell_properties:
70+
logger.warning(
71+
"StructuredGrid GOCAD export uses point properties; cell_properties were not exported"
72+
)
73+
elif grid.cell_properties:
74+
axis_n = np.asarray(grid.nsteps, dtype=int) - 1
75+
if np.any(axis_n <= 0):
76+
raise ValueError("StructuredGrid cell_properties require at least two grid nodes per axis")
77+
property_source = grid.cell_properties
78+
axis_min = np.min(grid.cell_centres, axis=0)
79+
axis_max = np.max(grid.cell_centres, axis=0)
80+
else:
81+
raise ValueError("StructuredGrid has no properties to export to GOCAD")
82+
83+
export_properties = []
84+
for index, (property_name, values) in enumerate(property_source.items(), start=1):
85+
export_info = _normalise_voxet_property(values, property_name, axis_n)
86+
safe_name = re.sub(r"[^0-9A-Za-z_-]+", "_", property_name).strip("_") or f"property_{index}"
87+
data_path = vo_path.with_name(f"{vo_path.stem}_{safe_name}@@")
88+
export_properties.append(
89+
{
90+
"index": index,
91+
"name": safe_name,
92+
"data_path": data_path,
93+
**export_info,
94+
}
95+
)
96+
97+
with open(vo_path, "w") as fp:
98+
fp.write(
99+
f"""GOCAD Voxet 1
100+
HEADER {{
101+
name: {grid.name}
102+
}}
103+
GOCAD_ORIGINAL_COORDINATE_SYSTEM
104+
NAME Default
105+
AXIS_NAME \"X\" \"Y\" \"Z\"
106+
AXIS_UNIT \"m\" \"m\" \"m\"
107+
ZPOSITIVE Elevation
108+
END_ORIGINAL_COORDINATE_SYSTEM
109+
AXIS_O 0.000000 0.000000 0.000000
110+
AXIS_U 1.000000 0.000000 0.000000
111+
AXIS_V 0.000000 1.000000 0.000000
112+
AXIS_W 0.000000 0.000000 1.000000
113+
AXIS_MIN {axis_min[0]} {axis_min[1]} {axis_min[2]}
114+
AXIS_MAX {axis_max[0]} {axis_max[1]} {axis_max[2]}
115+
AXIS_N {axis_n[0]} {axis_n[1]} {axis_n[2]}
116+
AXIS_NAME \"X\" \"Y\" \"Z\"
117+
AXIS_UNIT \"m\" \"m\" \"m\"
118+
AXIS_TYPE even even even
119+
"""
120+
)
121+
for export_property in export_properties:
122+
fp.write(
123+
f"""PROPERTY {export_property['index']} {export_property['name']}
124+
PROPERTY_CLASS {export_property['index']} {export_property['name']}
125+
PROP_UNIT {export_property['index']} {export_property['name']}
126+
PROPERTY_CLASS_HEADER {export_property['index']} {export_property['name']} {{
127+
}}
128+
PROPERTY_SUBCLASS {export_property['index']} QUANTITY {export_property['storage_type']}
129+
"""
130+
)
131+
if export_property["no_data_value"] is not None:
132+
fp.write(
133+
f"PROP_NO_DATA_VALUE {export_property['index']} {export_property['no_data_value']}\n"
134+
)
135+
fp.write(
136+
f"""PROP_ETYPE {export_property['index']} IEEE
137+
PROP_FORMAT {export_property['index']} RAW
138+
PROP_ESIZE {export_property['index']} {export_property['element_size']}
139+
PROP_OFFSET {export_property['index']} 0
140+
PROP_FILE {export_property['index']} {export_property['data_path'].name}
141+
"""
142+
)
143+
fp.write("END\n")
144+
145+
for export_property in export_properties:
146+
with open(export_property["data_path"], "wb") as fp:
147+
export_property["values"].tofile(fp)
148+
149+
return True
150+
3151

4152
def _write_feat_surfs_gocad(surf, file_name):
5153
"""
@@ -23,8 +171,6 @@ def _write_feat_surfs_gocad(surf, file_name):
23171
True if successful
24172
25173
"""
26-
from pathlib import Path
27-
28174
properties_header = None
29175
if surf.properties:
30176

@@ -112,8 +258,6 @@ def _write_feat_surfs_gocad(surf, file_name):
112258
# True if successful
113259

114260
# """
115-
# from pathlib import Path
116-
117261
# file_name = Path(file_name).with_suffix(".vs")
118262
# with open(f"{file_name}", "w") as fd:
119263
# fd.write(
@@ -123,4 +267,4 @@ def _write_feat_surfs_gocad(surf, file_name):
123267
# }}
124268
# GOCAD_ORIGINAL_COORDINATE_SYSTEM
125269
# NAME Default
126-
# PROJECTION Unknown
270+
# PROJECTION Unknown

LoopStructural/interpolators/supports/_2d_structured_grid.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,12 @@ def position_to_cell_corners(self, pos):
378378
----------
379379
pos : np.array
380380
(N, 2) array of xy coordinates representing the positions of N points.
381-
381+
382382
Returns
383383
-------
384384
globalidx : np.array
385-
(N, 4) array of global indices corresponding to the 4 corner nodes of the cell
386-
each point lies in. If a point lies outside the support, its corresponding entry
385+
(N, 4) array of global indices corresponding to the 4 corner nodes of the cell
386+
each point lies in. If a point lies outside the support, its corresponding entry
387387
will be set to -1.
388388
inside : np.array
389389
(N,) boolean array indicating whether each point is inside the support domain.
@@ -490,9 +490,36 @@ def position_to_cell_vertices(self, pos):
490490
def onGeometryChange(self):
491491
pass
492492

493-
def vtk(self, node_properties={}, cell_properties={}):
494-
raise NotImplementedError("VTK output not implemented for structured grid")
495-
pass
493+
def vtk(self, node_properties=None, cell_properties=None, z=0.0):
494+
"""
495+
Create a vtk unstructured grid from the mesh
496+
"""
497+
if node_properties is None:
498+
node_properties = {}
499+
if cell_properties is None:
500+
cell_properties = {}
501+
502+
try:
503+
import pyvista as pv
504+
except ImportError:
505+
raise ImportError("pyvista is required for this functionality")
506+
507+
from pyvista import CellType
508+
509+
points = np.zeros((self.n_nodes, 3))
510+
points[:, :2] = self.nodes
511+
points[:, 2] = z
512+
celltype = np.full(self.n_elements, CellType.QUAD, dtype=np.uint8)
513+
vtk_elements = self.elements[:, [0, 1, 3, 2]]
514+
elements = np.hstack(
515+
[np.full((vtk_elements.shape[0], 1), 4, dtype=int), vtk_elements.astype(np.int64)]
516+
).ravel()
517+
grid = pv.UnstructuredGrid(elements, celltype, points)
518+
for key, value in node_properties.items():
519+
grid.point_data[key] = value
520+
for key, value in cell_properties.items():
521+
grid.cell_data[key] = value
522+
return grid
496523

497524
def get_operators(self, weights: Dict[str, float]) -> Dict[str, Tuple[np.ndarray, float]]:
498525
"""Get
@@ -509,8 +536,8 @@ def get_operators(self, weights: Dict[str, float]) -> Dict[str, Tuple[np.ndarray
509536
"""
510537
# in a map we only want the xy operators
511538
operators = {
512-
'dxy': (Operator.Dxy_mask[1, :, :], weights['dxy'] * 2),
513-
'dxx': (Operator.Dxx_mask[1, :, :], weights['dxx']),
514-
'dyy': (Operator.Dyy_mask[1, :, :], weights['dyy']),
539+
"dxy": (Operator.Dxy_mask[1, :, :], weights["dxy"] * 2),
540+
"dxx": (Operator.Dxx_mask[1, :, :], weights["dxx"]),
541+
"dyy": (Operator.Dyy_mask[1, :, :], weights["dyy"]),
515542
}
516543
return operators

0 commit comments

Comments
 (0)