subsurface-terra 2025.1.0rc14__py3-none-any.whl → 2025.1.0rc16__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 (81) hide show
  1. subsurface/__init__.py +31 -31
  2. subsurface/_version.py +34 -21
  3. subsurface/api/__init__.py +13 -13
  4. subsurface/api/interfaces/__init__.py +3 -3
  5. subsurface/api/interfaces/stream.py +136 -136
  6. subsurface/api/reader/read_wells.py +78 -78
  7. subsurface/core/geological_formats/boreholes/_combine_trajectories.py +117 -117
  8. subsurface/core/geological_formats/boreholes/_map_attrs_to_survey.py +236 -0
  9. subsurface/core/geological_formats/boreholes/_survey_to_unstruct.py +163 -0
  10. subsurface/core/geological_formats/boreholes/boreholes.py +140 -116
  11. subsurface/core/geological_formats/boreholes/collars.py +26 -26
  12. subsurface/core/geological_formats/boreholes/survey.py +86 -380
  13. subsurface/core/geological_formats/fault.py +47 -47
  14. subsurface/core/reader_helpers/reader_unstruct.py +11 -11
  15. subsurface/core/reader_helpers/readers_data.py +130 -130
  16. subsurface/core/reader_helpers/readers_wells.py +13 -13
  17. subsurface/core/structs/__init__.py +3 -3
  18. subsurface/core/structs/base_structures/__init__.py +2 -2
  19. subsurface/core/structs/base_structures/_liquid_earth_mesh.py +121 -121
  20. subsurface/core/structs/base_structures/_unstructured_data_constructor.py +70 -70
  21. subsurface/core/structs/base_structures/base_structures_enum.py +6 -6
  22. subsurface/core/structs/base_structures/structured_data.py +282 -282
  23. subsurface/core/structs/base_structures/unstructured_data.py +319 -319
  24. subsurface/core/structs/structured_elements/octree_mesh.py +10 -10
  25. subsurface/core/structs/structured_elements/structured_grid.py +59 -59
  26. subsurface/core/structs/structured_elements/structured_mesh.py +9 -9
  27. subsurface/core/structs/unstructured_elements/__init__.py +3 -3
  28. subsurface/core/structs/unstructured_elements/line_set.py +72 -72
  29. subsurface/core/structs/unstructured_elements/point_set.py +43 -43
  30. subsurface/core/structs/unstructured_elements/tetrahedron_mesh.py +35 -35
  31. subsurface/core/structs/unstructured_elements/triangular_surface.py +62 -62
  32. subsurface/core/utils/utils_core.py +38 -38
  33. subsurface/modules/reader/__init__.py +13 -13
  34. subsurface/modules/reader/faults/faults.py +80 -80
  35. subsurface/modules/reader/from_binary.py +46 -46
  36. subsurface/modules/reader/mesh/_GOCAD_mesh.py +82 -82
  37. subsurface/modules/reader/mesh/_trimesh_reader.py +447 -447
  38. subsurface/modules/reader/mesh/csv_mesh_reader.py +53 -53
  39. subsurface/modules/reader/mesh/dxf_reader.py +177 -177
  40. subsurface/modules/reader/mesh/glb_reader.py +30 -30
  41. subsurface/modules/reader/mesh/mx_reader.py +232 -232
  42. subsurface/modules/reader/mesh/obj_reader.py +53 -53
  43. subsurface/modules/reader/mesh/omf_mesh_reader.py +43 -43
  44. subsurface/modules/reader/mesh/surface_reader.py +56 -56
  45. subsurface/modules/reader/mesh/surfaces_api.py +41 -41
  46. subsurface/modules/reader/profiles/__init__.py +3 -3
  47. subsurface/modules/reader/profiles/profiles_core.py +197 -197
  48. subsurface/modules/reader/read_netcdf.py +38 -38
  49. subsurface/modules/reader/topography/__init__.py +7 -7
  50. subsurface/modules/reader/topography/topo_core.py +100 -100
  51. subsurface/modules/reader/volume/read_grav3d.py +478 -428
  52. subsurface/modules/reader/volume/read_volume.py +327 -230
  53. subsurface/modules/reader/volume/segy_reader.py +105 -105
  54. subsurface/modules/reader/volume/seismic.py +173 -173
  55. subsurface/modules/reader/volume/volume_utils.py +43 -43
  56. subsurface/modules/reader/wells/DEP/__init__.py +43 -43
  57. subsurface/modules/reader/wells/DEP/_well_files_reader.py +167 -167
  58. subsurface/modules/reader/wells/DEP/_wells_api.py +61 -61
  59. subsurface/modules/reader/wells/DEP/_welly_reader.py +180 -180
  60. subsurface/modules/reader/wells/DEP/pandas_to_welly.py +212 -212
  61. subsurface/modules/reader/wells/_read_to_df.py +57 -57
  62. subsurface/modules/reader/wells/read_borehole_interface.py +148 -148
  63. subsurface/modules/reader/wells/wells_utils.py +68 -68
  64. subsurface/modules/tools/mocking_aux.py +104 -104
  65. subsurface/modules/visualization/__init__.py +2 -2
  66. subsurface/modules/visualization/to_pyvista.py +320 -320
  67. subsurface/modules/writer/to_binary.py +12 -12
  68. subsurface/modules/writer/to_rex/common.py +78 -78
  69. subsurface/modules/writer/to_rex/data_struct.py +74 -74
  70. subsurface/modules/writer/to_rex/gempy_to_rexfile.py +791 -791
  71. subsurface/modules/writer/to_rex/material_encoder.py +44 -44
  72. subsurface/modules/writer/to_rex/mesh_encoder.py +152 -152
  73. subsurface/modules/writer/to_rex/to_rex.py +115 -115
  74. subsurface/modules/writer/to_rex/utils.py +15 -15
  75. subsurface/optional_requirements.py +116 -116
  76. {subsurface_terra-2025.1.0rc14.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/METADATA +194 -194
  77. subsurface_terra-2025.1.0rc16.dist-info/RECORD +98 -0
  78. {subsurface_terra-2025.1.0rc14.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/WHEEL +1 -1
  79. {subsurface_terra-2025.1.0rc14.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/licenses/LICENSE +203 -203
  80. subsurface_terra-2025.1.0rc14.dist-info/RECORD +0 -96
  81. {subsurface_terra-2025.1.0rc14.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/top_level.txt +0 -0
@@ -1,428 +1,478 @@
1
- from dataclasses import dataclass, field
2
- from typing import List, Dict, Any, Tuple, Optional, Union, TextIO
3
- import numpy as np
4
- import xarray as xr
5
- from pathlib import Path
6
-
7
- from ....core.structs import StructuredData
8
-
9
-
10
- @dataclass
11
- class GridDimensions:
12
- """
13
- Represents the dimensions of a 3D grid.
14
-
15
- Attributes:
16
- nx (int): Number of cells in the x-direction
17
- ny (int): Number of cells in the y-direction
18
- nz (int): Number of cells in the z-direction
19
- """
20
- nx: int
21
- ny: int
22
- nz: int
23
-
24
-
25
- @dataclass
26
- class GridOrigin:
27
- """
28
- Represents the origin point of a 3D grid.
29
-
30
- Attributes:
31
- x (float): X-coordinate of the origin
32
- y (float): Y-coordinate of the origin
33
- z (float): Z-coordinate of the origin
34
- """
35
- x: float
36
- y: float
37
- z: float
38
-
39
-
40
- @dataclass
41
- class GridCellSizes:
42
- """
43
- Represents the cell sizes in each direction of a 3D grid.
44
-
45
- Attributes:
46
- x (List[float]): Cell sizes in the x-direction
47
- y (List[float]): Cell sizes in the y-direction
48
- z (List[float]): Cell sizes in the z-direction
49
- """
50
- x: List[float]
51
- y: List[float]
52
- z: List[float]
53
-
54
-
55
- @dataclass
56
- class GridData:
57
- """
58
- Represents a 3D grid with dimensions, origin, and cell sizes.
59
-
60
- Attributes:
61
- dimensions (GridDimensions): The dimensions of the grid
62
- origin (GridOrigin): The origin point of the grid
63
- cell_sizes (GridCellSizes): The cell sizes in each direction
64
- metadata (Dict[str, Any]): Optional metadata about the grid
65
- """
66
- dimensions: GridDimensions
67
- origin: GridOrigin
68
- cell_sizes: GridCellSizes
69
- metadata: Dict[str, Any] = field(default_factory=dict)
70
-
71
- @classmethod
72
- def from_dict(cls, grid_dict: Dict[str, Any]) -> 'GridData':
73
- """
74
- Converts a dictionary containing grid information into a GridData instance.
75
-
76
- Args:
77
- grid_dict: Dictionary with grid information
78
-
79
- Returns:
80
- GridData: A new GridData instance
81
- """
82
- dims = grid_dict["dimensions"]
83
- origin_dict = grid_dict["origin"]
84
- cell_sizes_dict = grid_dict["cell_sizes"]
85
-
86
- # Handle both new and legacy key names
87
- nx = dims.get("nx", dims.get("ne"))
88
- ny = dims.get("ny", dims.get("nn"))
89
- nz = dims.get("nz", dims.get("nz"))
90
-
91
- x = origin_dict.get("x", origin_dict.get("x0"))
92
- y = origin_dict.get("y", origin_dict.get("y0"))
93
- z = origin_dict.get("z", origin_dict.get("z0"))
94
-
95
- x_sizes = cell_sizes_dict.get("x", cell_sizes_dict.get("easting"))
96
- y_sizes = cell_sizes_dict.get("y", cell_sizes_dict.get("northing"))
97
- z_sizes = cell_sizes_dict.get("z", cell_sizes_dict.get("vertical"))
98
-
99
- metadata = grid_dict.get("metadata", {})
100
-
101
- return cls(
102
- dimensions=GridDimensions(nx=nx, ny=ny, nz=nz),
103
- origin=GridOrigin(x=x, y=y, z=z),
104
- cell_sizes=GridCellSizes(x=x_sizes, y=y_sizes, z=z_sizes),
105
- metadata=metadata
106
- )
107
-
108
-
109
- def read_msh_structured_grid(grid_stream: TextIO, values_stream: TextIO, missing_value: Optional[float],
110
- attr_name: Optional[str]) -> StructuredData:
111
- """
112
- Read a structured grid mesh and values from streams and return a StructuredData object.
113
-
114
- This function is designed to work with streams (e.g., from Azure blob storage)
115
- rather than file paths.
116
-
117
- Args:
118
- grid_stream: TextIO stream containing the grid definition (.msh format)
119
- values_stream: TextIO stream containing the property values (.mod format)
120
-
121
- Returns:
122
- StructuredData object containing the grid and property values
123
-
124
- Raises:
125
- ValueError: If the stream format is invalid
126
- """
127
- # Read all lines from the grid stream
128
- lines = [line.strip() for line in grid_stream if line.strip()]
129
-
130
- # Create metadata for the grid
131
- metadata = {
132
- 'file_format': 'grav3d',
133
- 'source' : 'stream'
134
- }
135
-
136
- # Parse grid information from lines
137
- try:
138
- grid = _parse_grid_from_lines(lines, metadata)
139
- except ValueError as e:
140
- # Add context about the stream to the error message
141
- raise ValueError(f"Error parsing grid stream: {e}") from e
142
-
143
- # Read values from the values stream
144
- try:
145
- # Read all values from the stream
146
- lines = [line.strip() for line in values_stream if line.strip()]
147
-
148
- model_array = _parse_mod_file(grid, lines, missing_value=missing_value)
149
-
150
- except Exception as e:
151
- # Add context to any errors
152
- raise ValueError(f"Error reading model stream: {str(e)}") from e
153
-
154
- # Create and return a StructuredData object
155
- return structured_data_from(model_array, grid, data_name=attr_name)
156
-
157
-
158
- def read_msh_file(filepath: Union[str, Path]) -> GridData:
159
- """
160
- Read a structured grid mesh file and return a GridData object.
161
-
162
- Currently supports Grav3D mesh file format (.msh):
163
- - First line: NX NY NZ (number of cells in X, Y, Z directions)
164
- - Second line: X Y Z (coordinates of origin in meters)
165
- - Next section: X cell widths (either expanded or using N*value notation)
166
- - Next section: Y cell widths (either expanded or using N*value notation)
167
- - Next section: Z cell thicknesses (either expanded or using N*value notation)
168
-
169
- Args:
170
- filepath: Path to the mesh file
171
-
172
- Returns:
173
- GridData object containing the mesh information
174
-
175
- Raises:
176
- FileNotFoundError: If the file doesn't exist
177
- ValueError: If the file format is invalid
178
- """
179
- filepath = Path(filepath)
180
- if not filepath.exists():
181
- raise FileNotFoundError(f"Mesh file not found: {filepath}")
182
-
183
- with open(filepath, 'r') as f:
184
- lines = [line.strip() for line in f.readlines() if line.strip()]
185
-
186
- metadata = {
187
- 'file_format': 'grav3d',
188
- 'filepath' : str(filepath)
189
- }
190
-
191
- try:
192
- return _parse_grid_from_lines(lines, metadata)
193
- except ValueError as e:
194
- # Add context about the file to the error message
195
- raise ValueError(f"Error parsing mesh file {filepath}: {e}") from e
196
-
197
-
198
- def read_mod_file(filepath: Union[str, Path], grid: GridData,
199
- missing_value: float = -99_999.0) -> np.ndarray:
200
- """
201
- Read a model file containing property values for a 3D grid.
202
-
203
- Currently supports Grav3D model file format (.mod) where each line contains
204
- a single property value. The values are ordered with the z-direction changing
205
- fastest, then x, then y.
206
-
207
- Args:
208
- filepath: Path to the model file
209
- grid: GridData object containing the grid dimensions
210
- missing_value: Value to replace with NaN in the output array (default: -99_999.0)
211
-
212
- Returns:
213
- 3D numpy array of property values with shape (ny, nx, nz)
214
-
215
- Raises:
216
- FileNotFoundError: If the file doesn't exist
217
- ValueError: If the number of values doesn't match the grid dimensions
218
- """
219
- filepath = Path(filepath)
220
- if not filepath.exists():
221
- raise FileNotFoundError(f"Model file not found: {filepath}")
222
-
223
- try:
224
- # Read all values from the file
225
- with open(filepath, 'r') as f:
226
- lines = [line.strip() for line in f if line.strip()]
227
-
228
- model_array = _parse_mod_file(grid, lines, missing_value)
229
-
230
- return model_array
231
-
232
- except Exception as e:
233
- # Add context to any errors
234
- raise ValueError(f"Error reading model file {filepath}: {str(e)}") from e
235
-
236
-
237
- def _parse_mod_file(grid: GridData, lines: List[str], missing_value: Optional[float]) -> np.ndarray:
238
- # Convert each line to a float
239
- values = np.array([float(line) for line in lines], dtype=float)
240
- # Calculate expected number of values based on grid dimensions
241
- nx, ny, nz = grid.dimensions.nx, grid.dimensions.ny, grid.dimensions.nz
242
- expected_count = nx * ny * nz
243
- if len(values) != expected_count:
244
- raise ValueError(
245
- f"Invalid model file: expected {expected_count} values, got {len(values)}"
246
- )
247
- # Reshape to (ny, nx, nz) with z changing fastest
248
- model_array = values.reshape((ny, nx, nz))
249
- # Replace missing values with NaN
250
- if missing_value is not None:
251
- model_array[model_array == missing_value] = np.nan
252
- return model_array
253
-
254
-
255
- def structured_data_from(array: np.ndarray, grid: GridData,
256
- data_name: str = 'model') -> StructuredData:
257
- """
258
- Convert a 3D numpy array and grid information into a StructuredData object.
259
-
260
- Args:
261
- array: 3D numpy array of property values with shape (ny, nx, nz)
262
- grid: GridData object containing grid dimensions, origin, and cell sizes
263
- data_name: Name for the data array (default: 'model')
264
-
265
- Returns:
266
- StructuredData object containing the data array with proper coordinates
267
-
268
- Raises:
269
- ValueError: If array shape doesn't match grid dimensions
270
- """
271
- # Verify array shape matches grid dimensions
272
- expected_shape = (grid.dimensions.ny, grid.dimensions.nx, grid.dimensions.nz)
273
- if array.shape != expected_shape:
274
- raise ValueError(
275
- f"Array shape {array.shape} doesn't match grid dimensions {expected_shape}"
276
- )
277
-
278
- # Calculate cell center coordinates
279
- centers = _calculate_cell_centers(grid)
280
-
281
- # Create the xarray DataArray with proper coordinates
282
- xr_data_array = xr.DataArray(
283
- data=array,
284
- dims=['y', 'x', 'z'], # Dimensions in the order they appear in the array
285
- coords={
286
- 'x': centers['x'],
287
- 'y': centers['y'],
288
- 'z': centers['z'],
289
- },
290
- name=data_name,
291
- attrs=grid.metadata # Include grid metadata in the data array
292
- )
293
-
294
- # Create a StructuredData instance from the xarray DataArray
295
- struct = StructuredData.from_data_array(
296
- data_array=xr_data_array,
297
- data_array_name=data_name
298
- )
299
-
300
- return struct
301
-
302
-
303
- def _parse_grid_from_lines(lines: List[str], metadata: Dict[str, Any] = None) -> GridData:
304
- """
305
- Parse grid information from a list of lines.
306
-
307
- Args:
308
- lines: List of lines containing grid information
309
- metadata: Optional metadata to include in the GridData object
310
-
311
- Returns:
312
- GridData object containing the parsed grid information
313
-
314
- Raises:
315
- ValueError: If the lines format is invalid
316
- """
317
- if len(lines) < 2:
318
- raise ValueError("Invalid format: insufficient data")
319
-
320
- # Parse dimensions (first line)
321
- try:
322
- dims = lines[0].split()
323
- nx, ny, nz = int(dims[0]), int(dims[1]), int(dims[2])
324
- except (IndexError, ValueError) as e:
325
- raise ValueError(f"Invalid dimensions: {e}")
326
-
327
- # Parse origin coordinates (second line)
328
- try:
329
- origin = lines[1].split()
330
- x, y, z = float(origin[0]), float(origin[1]), float(origin[2])
331
- except (IndexError, ValueError) as e:
332
- raise ValueError(f"Invalid origin: {e}")
333
-
334
- # Parse cell sizes
335
- try:
336
- current_line = 2
337
- x_sizes, current_line = _parse_cell_sizes(lines, current_line, nx)
338
- y_sizes, current_line = _parse_cell_sizes(lines, current_line, ny)
339
- z_sizes, _ = _parse_cell_sizes(lines, current_line, nz)
340
- except (IndexError, ValueError) as e:
341
- raise ValueError(f"Error parsing cell sizes: {e}")
342
-
343
- # Create a GridData object with the parsed information
344
- grid_data_dict = {
345
- 'dimensions': {'nx': nx, 'ny': ny, 'nz': nz},
346
- 'origin' : {'x': x, 'y': y, 'z': z},
347
- 'cell_sizes': {
348
- 'x': x_sizes,
349
- 'y': y_sizes,
350
- 'z': z_sizes
351
- },
352
- 'metadata' : metadata or {}
353
- }
354
-
355
- return GridData.from_dict(grid_data_dict)
356
-
357
-
358
- def _parse_cell_sizes(lines: List[str], start_index: int, count: int) -> Tuple[List[float], int]:
359
- """
360
- Parse cell sizes from file lines, handling both compact (N*value) and expanded notation.
361
-
362
- Args:
363
- lines: List of lines from the file
364
- start_index: Index to start parsing from
365
- count: Number of values to parse
366
-
367
- Returns:
368
- Tuple containing:
369
- - List of parsed values
370
- - Next line index after parsing
371
- """
372
- line = lines[start_index]
373
-
374
- # Check for compact notation (N*value)
375
- if '*' in line:
376
- parts = line.split('*')
377
- repetition = int(parts[0])
378
- value = float(parts[1])
379
- values = [value] * repetition
380
- return values, start_index + 1
381
-
382
- # Handle expanded notation across multiple lines
383
- values = []
384
- line_index = start_index
385
-
386
- while len(values) < count and line_index < len(lines):
387
- current_line = lines[line_index]
388
-
389
- # If we encounter a line with compact notation while parsing expanded,
390
- # it's likely the next section
391
- if '*' in current_line and len(values) > 0:
392
- break
393
-
394
- # Add all numbers from the current line
395
- values.extend([float(x) for x in current_line.split()])
396
- line_index += 1
397
-
398
- # Take only the required number of values
399
- return values[:count], line_index
400
-
401
-
402
- def _calculate_cell_centers(grid: GridData) -> Dict[str, np.ndarray]:
403
- """
404
- Calculate the center coordinates of each cell in the grid.
405
-
406
- Args:
407
- grid: GridData object containing grid dimensions, origin, and cell sizes
408
-
409
- Returns:
410
- Dictionary with 'x', 'y', and 'z' keys containing arrays of cell center coordinates
411
- """
412
- # Convert cell sizes to numpy arrays for vectorized operations
413
- x_sizes = np.array(grid.cell_sizes.x)
414
- y_sizes = np.array(grid.cell_sizes.y)
415
- z_sizes = np.array(grid.cell_sizes.z)
416
-
417
- # Calculate cell centers by adding cumulative sizes and offsetting by half the first cell size
418
- x_centers = grid.origin.x + np.cumsum(x_sizes) - x_sizes[0] / 2
419
- y_centers = grid.origin.y + np.cumsum(y_sizes) - y_sizes[0] / 2
420
-
421
- # For z, cells typically extend downward from the origin
422
- z_centers = grid.origin.z - (np.cumsum(z_sizes) - z_sizes[0] / 2)
423
-
424
- return {
425
- 'x': x_centers,
426
- 'y': y_centers,
427
- 'z': z_centers
428
- }
1
+ from dataclasses import dataclass, field
2
+ from typing import List, Dict, Any, Tuple, Optional, Union, TextIO
3
+ import numpy as np
4
+ import xarray as xr
5
+ from pathlib import Path
6
+
7
+ from ....core.structs import StructuredData
8
+
9
+
10
+ @dataclass
11
+ class GridDimensions:
12
+ """
13
+ Represents the dimensions of a 3D grid.
14
+
15
+ Attributes:
16
+ nx (int): Number of cells in the x-direction
17
+ ny (int): Number of cells in the y-direction
18
+ nz (int): Number of cells in the z-direction
19
+ """
20
+ nx: int
21
+ ny: int
22
+ nz: int
23
+
24
+
25
+ @dataclass
26
+ class GridOrigin:
27
+ """
28
+ Represents the origin point of a 3D grid.
29
+
30
+ Attributes:
31
+ x (float): X-coordinate of the origin
32
+ y (float): Y-coordinate of the origin
33
+ z (float): Z-coordinate of the origin
34
+ """
35
+ x: float
36
+ y: float
37
+ z: float
38
+
39
+
40
+ @dataclass
41
+ class GridCellSizes:
42
+ """
43
+ Represents the cell sizes in each direction of a 3D grid.
44
+
45
+ Attributes:
46
+ x (List[float]): Cell sizes in the x-direction
47
+ y (List[float]): Cell sizes in the y-direction
48
+ z (List[float]): Cell sizes in the z-direction
49
+ """
50
+ x: List[float]
51
+ y: List[float]
52
+ z: List[float]
53
+
54
+
55
+ @dataclass
56
+ class GridData:
57
+ """
58
+ Represents a 3D grid with dimensions, origin, and cell sizes.
59
+
60
+ Attributes:
61
+ dimensions (GridDimensions): The dimensions of the grid
62
+ origin (GridOrigin): The origin point of the grid
63
+ cell_sizes (GridCellSizes): The cell sizes in each direction
64
+ metadata (Dict[str, Any]): Optional metadata about the grid
65
+ """
66
+ dimensions: GridDimensions
67
+ origin: GridOrigin
68
+ cell_sizes: GridCellSizes
69
+ metadata: Dict[str, Any] = field(default_factory=dict)
70
+
71
+ @classmethod
72
+ def from_dict(cls, grid_dict: Dict[str, Any]) -> 'GridData':
73
+ """
74
+ Converts a dictionary containing grid information into a GridData instance.
75
+
76
+ Args:
77
+ grid_dict: Dictionary with grid information
78
+
79
+ Returns:
80
+ GridData: A new GridData instance
81
+ """
82
+ dims = grid_dict["dimensions"]
83
+ origin_dict = grid_dict["origin"]
84
+ cell_sizes_dict = grid_dict["cell_sizes"]
85
+
86
+ # Handle both new and legacy key names
87
+ nx = dims.get("nx", dims.get("ne"))
88
+ ny = dims.get("ny", dims.get("nn"))
89
+ nz = dims.get("nz", dims.get("nz"))
90
+
91
+ x = origin_dict.get("x", origin_dict.get("x0"))
92
+ y = origin_dict.get("y", origin_dict.get("y0"))
93
+ z = origin_dict.get("z", origin_dict.get("z0"))
94
+
95
+ x_sizes = cell_sizes_dict.get("x", cell_sizes_dict.get("easting"))
96
+ y_sizes = cell_sizes_dict.get("y", cell_sizes_dict.get("northing"))
97
+ z_sizes = cell_sizes_dict.get("z", cell_sizes_dict.get("vertical"))
98
+
99
+ metadata = grid_dict.get("metadata", {})
100
+
101
+ return cls(
102
+ dimensions=GridDimensions(nx=nx, ny=ny, nz=nz),
103
+ origin=GridOrigin(x=x, y=y, z=z),
104
+ cell_sizes=GridCellSizes(x=x_sizes, y=y_sizes, z=z_sizes),
105
+ metadata=metadata
106
+ )
107
+
108
+
109
+ from typing import Literal
110
+
111
+ def read_msh_structured_grid(grid_stream: TextIO, values_stream: TextIO, missing_value: Optional[float],
112
+ attr_name: Optional[str], ordering: Literal['ijk', 'xyz', 'xyz_reverse'] = 'ijk') -> StructuredData:
113
+ """
114
+ Read a structured grid mesh and values from streams and return a StructuredData object.
115
+
116
+ This function is designed to work with streams (e.g., from Azure blob storage)
117
+ rather than file paths.
118
+
119
+ Args:
120
+ grid_stream: TextIO stream containing the grid definition (.msh format)
121
+ values_stream: TextIO stream containing the property values (.mod format)
122
+ missing_value: Value to replace with NaN in the output array
123
+ attr_name: Name for the data attribute
124
+ ordering: Data ordering in the file:
125
+ - 'ijk': i (x) varies fastest, then j (y), then k (z)
126
+ - 'xyz': z varies fastest, then x, then y
127
+ - 'xyz_reverse': z varies fastest (reversed), then x, then y
128
+ Default is 'ijk'.
129
+
130
+ Returns:
131
+ StructuredData object containing the grid and property values
132
+
133
+ Raises:
134
+ ValueError: If the stream format is invalid
135
+ """
136
+ # Read all lines from the grid stream
137
+ lines = [line.strip() for line in grid_stream if line.strip()]
138
+
139
+ # Create metadata for the grid
140
+ metadata = {
141
+ 'file_format': 'grav3d',
142
+ 'source' : 'stream'
143
+ }
144
+
145
+ # Parse grid information from lines
146
+ try:
147
+ grid = _parse_grid_from_lines(lines, metadata)
148
+ except ValueError as e:
149
+ # Add context about the stream to the error message
150
+ raise ValueError(f"Error parsing grid stream: {e}") from e
151
+
152
+ # Read values from the values stream
153
+ try:
154
+ # Read all values from the stream
155
+ lines = [line.strip() for line in values_stream if line.strip()]
156
+
157
+ model_array = _parse_mod_file(grid, lines, missing_value=missing_value, ordering=ordering)
158
+
159
+ except Exception as e:
160
+ # Add context to any errors
161
+ raise ValueError(f"Error reading model stream: {str(e)}") from e
162
+
163
+ # Create and return a StructuredData object
164
+ return structured_data_from(model_array, grid, data_name=attr_name)
165
+
166
+
167
+ def read_msh_file(filepath: Union[str, Path]) -> GridData:
168
+ """
169
+ Read a structured grid mesh file and return a GridData object.
170
+
171
+ Currently supports Grav3D mesh file format (.msh):
172
+ - First line: NX NY NZ (number of cells in X, Y, Z directions)
173
+ - Second line: X Y Z (coordinates of origin in meters)
174
+ - Next section: X cell widths (either expanded or using N*value notation)
175
+ - Next section: Y cell widths (either expanded or using N*value notation)
176
+ - Next section: Z cell thicknesses (either expanded or using N*value notation)
177
+
178
+ Args:
179
+ filepath: Path to the mesh file
180
+
181
+ Returns:
182
+ GridData object containing the mesh information
183
+
184
+ Raises:
185
+ FileNotFoundError: If the file doesn't exist
186
+ ValueError: If the file format is invalid
187
+ """
188
+ filepath = Path(filepath)
189
+ if not filepath.exists():
190
+ raise FileNotFoundError(f"Mesh file not found: {filepath}")
191
+
192
+ with open(filepath, 'r') as f:
193
+ lines = [line.strip() for line in f.readlines() if line.strip()]
194
+
195
+ metadata = {
196
+ 'file_format': 'grav3d',
197
+ 'filepath' : str(filepath)
198
+ }
199
+
200
+ try:
201
+ return _parse_grid_from_lines(lines, metadata)
202
+ except ValueError as e:
203
+ # Add context about the file to the error message
204
+ raise ValueError(f"Error parsing mesh file {filepath}: {e}") from e
205
+
206
+
207
+ def read_mod_file(filepath: Union[str, Path], grid: GridData,
208
+ missing_value: float = -99_999.0,
209
+ ordering: Literal['ijk', 'xyz', 'xyz_reverse'] = 'ijk') -> np.ndarray:
210
+ """
211
+ Read a model file containing property values for a 3D grid.
212
+
213
+ Currently supports Grav3D model file format (.mod) where each line contains
214
+ a single property value.
215
+
216
+ Args:
217
+ filepath: Path to the model file
218
+ grid: GridData object containing the grid dimensions
219
+ missing_value: Value to replace with NaN in the output array (default: -99_999.0)
220
+ ordering: Data ordering in the file. Options:
221
+ - 'ijk': i (x) varies fastest, then j (y), then k (z) - standard VTK/Fortran ordering
222
+ - 'xyz': z varies fastest, then x, then y - legacy Grav3D ordering
223
+ - 'xyz_reverse': z varies fastest (reversed direction), then x, then y
224
+ Default is 'ijk'.
225
+
226
+ Returns:
227
+ 3D numpy array of property values with shape (ny, nx, nz)
228
+
229
+ Raises:
230
+ FileNotFoundError: If the file doesn't exist
231
+ ValueError: If the number of values doesn't match the grid dimensions
232
+ """
233
+ filepath = Path(filepath)
234
+ if not filepath.exists():
235
+ raise FileNotFoundError(f"Model file not found: {filepath}")
236
+
237
+ try:
238
+ # Read all values from the file
239
+ with open(filepath, 'r') as f:
240
+ lines = [line.strip() for line in f if line.strip()]
241
+
242
+ model_array = _parse_mod_file(grid, lines, missing_value, ordering)
243
+
244
+ return model_array
245
+
246
+ except Exception as e:
247
+ # Add context to any errors
248
+ raise ValueError(f"Error reading model file {filepath}: {str(e)}") from e
249
+
250
+
251
+ def structured_data_from(array: np.ndarray, grid: GridData,
252
+ data_name: str = 'model') -> StructuredData:
253
+ """
254
+ Convert a 3D numpy array and grid information into a StructuredData object.
255
+
256
+ Args:
257
+ array: 3D numpy array of property values with shape (ny, nx, nz)
258
+ grid: GridData object containing grid dimensions, origin, and cell sizes
259
+ data_name: Name for the data array (default: 'model')
260
+
261
+ Returns:
262
+ StructuredData object containing the data array with proper coordinates
263
+
264
+ Raises:
265
+ ValueError: If array shape doesn't match grid dimensions
266
+ """
267
+ # Verify array shape matches grid dimensions
268
+ expected_shape = (grid.dimensions.ny, grid.dimensions.nx, grid.dimensions.nz)
269
+ if array.shape != expected_shape:
270
+ raise ValueError(
271
+ f"Array shape {array.shape} doesn't match grid dimensions {expected_shape}"
272
+ )
273
+
274
+ # Calculate cell center coordinates
275
+ centers = _calculate_cell_centers(grid)
276
+
277
+ # Create the xarray DataArray with proper coordinates
278
+ xr_data_array = xr.DataArray(
279
+ data=array,
280
+ dims=['y', 'x', 'z'], # Dimensions in the order they appear in the array
281
+ coords={
282
+ 'x': centers['x'],
283
+ 'y': centers['y'],
284
+ 'z': centers['z'],
285
+ },
286
+ name=data_name,
287
+ attrs=grid.metadata # Include grid metadata in the data array
288
+ )
289
+
290
+ # Create a StructuredData instance from the xarray DataArray
291
+ struct = StructuredData.from_data_array(
292
+ data_array=xr_data_array,
293
+ data_array_name=data_name
294
+ )
295
+
296
+ return struct
297
+
298
+
299
+ def _parse_grid_from_lines(lines: List[str], metadata: Dict[str, Any] = None) -> GridData:
300
+ """
301
+ Parse grid information from a list of lines.
302
+
303
+ Args:
304
+ lines: List of lines containing grid information
305
+ metadata: Optional metadata to include in the GridData object
306
+
307
+ Returns:
308
+ GridData object containing the parsed grid information
309
+
310
+ Raises:
311
+ ValueError: If the lines format is invalid
312
+ """
313
+ if len(lines) < 2:
314
+ raise ValueError("Invalid format: insufficient data")
315
+
316
+ # Parse dimensions (first line)
317
+ try:
318
+ dims = lines[0].split()
319
+ nx, ny, nz = int(dims[0]), int(dims[1]), int(dims[2])
320
+ except (IndexError, ValueError) as e:
321
+ raise ValueError(f"Invalid dimensions: {e}")
322
+
323
+ # Parse origin coordinates (second line)
324
+ try:
325
+ origin = lines[1].split()
326
+ x, y, z = float(origin[0]), float(origin[1]), float(origin[2])
327
+ except (IndexError, ValueError) as e:
328
+ raise ValueError(f"Invalid origin: {e}")
329
+
330
+ # Parse cell sizes
331
+ try:
332
+ current_line = 2
333
+ x_sizes, current_line = _parse_cell_sizes(lines, current_line, nx)
334
+ y_sizes, current_line = _parse_cell_sizes(lines, current_line, ny)
335
+ z_sizes, _ = _parse_cell_sizes(lines, current_line, nz)
336
+ except (IndexError, ValueError) as e:
337
+ raise ValueError(f"Error parsing cell sizes: {e}")
338
+
339
+ # Create a GridData object with the parsed information
340
+ grid_data_dict = {
341
+ 'dimensions': {'nx': nx, 'ny': ny, 'nz': nz},
342
+ 'origin' : {'x': x, 'y': y, 'z': z},
343
+ 'cell_sizes': {
344
+ 'x': x_sizes,
345
+ 'y': y_sizes,
346
+ 'z': z_sizes
347
+ },
348
+ 'metadata' : metadata or {}
349
+ }
350
+
351
+ return GridData.from_dict(grid_data_dict)
352
+
353
+
354
+ def _parse_cell_sizes(lines: List[str], start_index: int, count: int) -> Tuple[List[float], int]:
355
+ """
356
+ Parse cell sizes from file lines, handling both compact (N*value) and expanded notation.
357
+
358
+ Args:
359
+ lines: List of lines from the file
360
+ start_index: Index to start parsing from
361
+ count: Number of values to parse
362
+
363
+ Returns:
364
+ Tuple containing:
365
+ - List of parsed values
366
+ - Next line index after parsing
367
+ """
368
+ line = lines[start_index]
369
+
370
+ # Check for compact notation (N*value)
371
+ if '*' in line:
372
+ parts = line.split('*')
373
+ repetition = int(parts[0])
374
+ value = float(parts[1])
375
+ values = [value] * repetition
376
+ return values, start_index + 1
377
+
378
+ # Handle expanded notation across multiple lines
379
+ values = []
380
+ line_index = start_index
381
+
382
+ while len(values) < count and line_index < len(lines):
383
+ current_line = lines[line_index]
384
+
385
+ # If we encounter a line with compact notation while parsing expanded,
386
+ # it's likely the next section
387
+ if '*' in current_line and len(values) > 0:
388
+ break
389
+
390
+ # Add all numbers from the current line
391
+ values.extend([float(x) for x in current_line.split()])
392
+ line_index += 1
393
+
394
+ # Take only the required number of values
395
+ return values[:count], line_index
396
+
397
+
398
+ def _calculate_cell_centers(grid: GridData) -> Dict[str, np.ndarray]:
399
+ """
400
+ Calculate the center coordinates of each cell in the grid.
401
+
402
+ Args:
403
+ grid: GridData object containing grid dimensions, origin, and cell sizes
404
+
405
+ Returns:
406
+ Dictionary with 'x', 'y', and 'z' keys containing arrays of cell center coordinates
407
+ """
408
+ # Convert cell sizes to numpy arrays for vectorized operations
409
+ x_sizes = np.array(grid.cell_sizes.x)
410
+ y_sizes = np.array(grid.cell_sizes.y)
411
+ z_sizes = np.array(grid.cell_sizes.z)
412
+
413
+ # Calculate cell centers by adding cumulative sizes and offsetting by half the first cell size
414
+ x_centers = grid.origin.x + np.cumsum(x_sizes) - x_sizes[0] / 2
415
+ y_centers = grid.origin.y + np.cumsum(y_sizes) - y_sizes[0] / 2
416
+
417
+ # For z, cells typically extend downward from the origin
418
+ z_centers = grid.origin.z - (np.cumsum(z_sizes) - z_sizes[0] / 2)
419
+
420
+ return {
421
+ 'x': x_centers,
422
+ 'y': y_centers,
423
+ 'z': z_centers
424
+ }
425
+
426
+
427
+ def _parse_mod_file(grid: GridData, lines: List[str], missing_value: Optional[float],
428
+ ordering: Literal['ijk', 'xyz', 'xyz_reverse'] = 'ijk') -> np.ndarray:
429
+ """
430
+ Parse model file values into a 3D numpy array.
431
+
432
+ Args:
433
+ grid: GridData object containing grid dimensions
434
+ lines: List of lines containing the values
435
+ missing_value: Value to replace with NaN
436
+ ordering: Data ordering in the file:
437
+ - 'ijk': i (x) varies fastest, then j (y), then k (z)
438
+ - 'xyz': z varies fastest, then x, then y
439
+ - 'xyz_reverse': z varies fastest (reversed), then x, then y
440
+
441
+ Returns:
442
+ 3D numpy array with shape (ny, nx, nz)
443
+ """
444
+ # Convert each line to a float
445
+ values = np.array([float(line) for line in lines], dtype=float)
446
+
447
+ # Calculate expected number of values based on grid dimensions
448
+ nx, ny, nz = grid.dimensions.nx, grid.dimensions.ny, grid.dimensions.nz
449
+ expected_count = nx * ny * nz
450
+
451
+ if len(values) != expected_count:
452
+ raise ValueError(
453
+ f"Invalid model file: expected {expected_count} values, got {len(values)}"
454
+ )
455
+
456
+ # Reshape based on ordering
457
+ if ordering == 'ijk':
458
+ # i (x) varies fastest, then j (y), then k (z)
459
+ # This is standard VTK/Fortran ordering: (k, j, i) in array dimensions
460
+ model_array = values.reshape((nz, ny, nx), order='C')
461
+ # Transpose to (ny, nx, nz) to match expected output shape
462
+ model_array = np.transpose(model_array, (1, 2, 0))
463
+ elif ordering == 'xyz':
464
+ # z varies fastest, then x, then y (legacy Grav3D ordering)
465
+ model_array = values.reshape((ny, nx, nz))
466
+ elif ordering == 'xyz_reverse':
467
+ # z varies fastest (but in reverse direction), then x, then y
468
+ model_array = values.reshape((ny, nx, nz))
469
+ # Reverse the z-axis (last dimension)
470
+ model_array = np.flip(model_array, axis=2)
471
+ else:
472
+ raise ValueError(f"Invalid ordering: {ordering}. Must be 'ijk', 'xyz', or 'xyz_reverse'")
473
+
474
+ # Replace missing values with NaN
475
+ if missing_value is not None:
476
+ model_array[model_array == missing_value] = np.nan
477
+
478
+ return model_array