Skip to content

Commit 0892dcc

Browse files
committed
fix: adding gocad export for structured grid support
1 parent 4659b47 commit 0892dcc

4 files changed

Lines changed: 242 additions & 26 deletions

File tree

LoopStructural/datatypes/_structured_grid.py

Lines changed: 16 additions & 18 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]))
@@ -134,15 +135,15 @@ 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):
141142
x = np.linspace(self.origin[0], self.maximum[0], self.nsteps[0])
142143
y = np.linspace(self.origin[1], self.maximum[1], self.nsteps[1])
143144
z = np.linspace(self.origin[2], self.maximum[2], self.nsteps[2])
144145
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
146+
return np.vstack([x.flatten(order="f"), y.flatten(order="f"), z.flatten(order="f")]).T
146147

147148
def merge(self, other):
148149
if not np.all(np.isclose(self.origin, other.origin)):
@@ -157,36 +158,33 @@ def merge(self, other):
157158
for name, data in other.properties.items():
158159
self.properties[name] = data
159160

160-
def save(self, filename, *,group='Loop'):
161+
def save(self, filename, *, group="Loop"):
161162
filename = str(filename)
162-
ext = filename.split('.')[-1]
163-
if ext == 'json':
163+
ext = filename.split(".")[-1].lower()
164+
if ext == "json":
164165
import json
165166

166-
with open(filename, 'w') as f:
167+
with open(filename, "w") as f:
167168
json.dump(self.to_dict(), f)
168-
elif ext == 'vtk':
169+
elif ext == "vtk":
169170
self.vtk().save(filename)
170171

171-
elif ext == 'geoh5':
172+
elif ext == "geoh5":
172173
from LoopStructural.export.geoh5 import add_structured_grid_to_geoh5
173174

174175
add_structured_grid_to_geoh5(filename, self, groupname=group)
175-
elif ext == 'pkl':
176+
elif ext == "pkl":
176177
import pickle
177178

178-
with open(filename, 'wb') as f:
179+
with open(filename, "wb") as f:
179180
pickle.dump(self, f)
180-
elif ext == 'omf':
181+
elif ext == "omf":
181182
from LoopStructural.export.omf_wrapper import add_structured_grid_to_omf
182183

183184
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
185+
elif ext == "vo" or ext == "gocad":
186+
from LoopStructural.export.gocad import _write_structured_grid_gocad
189187

190-
# _write_pointset(self, filename)
188+
_write_structured_grid_gocad(self, filename)
191189
else:
192-
raise ValueError(f'Unknown file extension {ext}')
190+
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": property_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

tests/unit/datatypes/test__structured_grid.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def test_structured_grid_to_dict():
88
origin = np.array([0, 0, 0])
99
nsteps = np.array([10, 10, 10])
1010
step_vector = np.array([1, 1, 1])
11-
data = {'rng': rng.random(size=(10, 10, 10))}
11+
data = {"rng": rng.random(size=(10, 10, 10))}
1212
name = "grid_data"
1313

1414
grid = StructuredGrid(
@@ -55,7 +55,7 @@ def test_structured_grid_vtk():
5555
origin = np.array([0, 0, 0])
5656
nsteps = np.array([10, 10, 10])
5757
step_vector = np.array([1, 1, 1])
58-
data = {'rng': rng.random(size=(10, 10, 10))}
58+
data = {"rng": rng.random(size=(10, 10, 10))}
5959
name = "grid_data"
6060

6161
grid = StructuredGrid(
@@ -74,7 +74,65 @@ def test_structured_grid_vtk():
7474
assert isinstance(vtk_grid, pv.RectilinearGrid)
7575
assert np.array_equal(vtk_grid.dimensions, nsteps)
7676
assert np.array_equal(grid_origin, origin)
77-
assert np.array_equal(vtk_grid['rng'], data['rng'].flatten(order="F"))
77+
assert np.array_equal(vtk_grid["rng"], data["rng"].flatten(order="F"))
78+
79+
80+
def test_structured_grid_save_gocad_voxet(tmp_path):
81+
origin = np.array([10.0, 20.0, 30.0])
82+
nsteps = np.array([2, 3, 4])
83+
step_vector = np.array([1.0, 2.0, 3.0])
84+
values = np.arange(np.prod(nsteps), dtype=np.float32).reshape(tuple(nsteps), order="F")
85+
grid = StructuredGrid(
86+
origin=origin,
87+
step_vector=step_vector,
88+
nsteps=nsteps,
89+
properties={"scalar": values},
90+
cell_properties={},
91+
name="grid_data",
92+
)
93+
94+
output = tmp_path / "structured_grid.vo"
95+
grid.save(output)
96+
97+
header = output.read_text()
98+
binary_path = tmp_path / "structured_grid_scalar@@"
99+
100+
assert "GOCAD Voxet 1" in header
101+
assert "PROPERTY 1 scalar" in header
102+
assert f"AXIS_N {nsteps[0]} {nsteps[1]} {nsteps[2]}" in header
103+
assert "PROP_FILE 1 structured_grid_scalar@@" in header
104+
assert binary_path.exists()
105+
106+
exported = np.fromfile(binary_path, dtype=">f4")
107+
np.testing.assert_array_equal(exported, values.flatten(order="F"))
108+
109+
110+
def test_structured_grid_save_gocad_voxet_uses_cell_properties_when_needed(tmp_path):
111+
origin = np.array([0.0, 0.0, 0.0])
112+
nsteps = np.array([3, 3, 3])
113+
step_vector = np.array([1.0, 1.0, 1.0])
114+
values = np.arange(np.prod(nsteps - 1), dtype=np.float32).reshape(tuple(nsteps - 1), order="F")
115+
grid = StructuredGrid(
116+
origin=origin,
117+
step_vector=step_vector,
118+
nsteps=nsteps,
119+
properties={},
120+
cell_properties={"cells": values},
121+
name="cell_grid",
122+
)
123+
124+
output = tmp_path / "cell_grid.vo"
125+
grid.save(output)
126+
127+
header = output.read_text()
128+
binary_path = tmp_path / "cell_grid_cells@@"
129+
130+
assert "PROPERTY 1 cells" in header
131+
assert "AXIS_N 2 2 2" in header
132+
assert binary_path.exists()
133+
134+
exported = np.fromfile(binary_path, dtype=">f4")
135+
np.testing.assert_array_equal(exported, values.flatten(order="F"))
78136

79137

80138
if __name__ == "__main__":

tests/unit/interpolator/test_2d_discrete_support.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from LoopStructural.interpolators import StructuredGrid2D
22
import numpy as np
3+
import pyvista as pv
34

45

56
## structured grid 2d tests
@@ -59,3 +60,18 @@ def test_get_element_outside2d():
5960
point = np.array([grid.origin - np.ones(2)])
6061
idc, inside = grid.position_to_cell_corners(point)
6162
assert not inside[0]
63+
64+
65+
def test_structured_grid2d_vtk_assigns_quad_cell_types():
66+
grid = StructuredGrid2D(origin=np.zeros(2), nsteps=np.array([4, 4]))
67+
68+
vtk_grid = grid.vtk(z=0.0)
69+
70+
assert vtk_grid.n_cells == grid.n_elements
71+
assert vtk_grid.celltypes.shape == (grid.n_elements,)
72+
assert np.all(vtk_grid.celltypes == pv.CellType.QUAD)
73+
assert vtk_grid.get_cell(0).point_ids == [0, 1, 5, 4]
74+
75+
triangulated = vtk_grid.triangulate()
76+
77+
assert triangulated.n_cells == grid.n_elements * 2

0 commit comments

Comments
 (0)