evo-objects 0.6.1__tar.gz → 0.6.2__tar.gz

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 (47) hide show
  1. {evo_objects-0.6.1 → evo_objects-0.6.2}/PKG-INFO +2 -2
  2. {evo_objects-0.6.1 → evo_objects-0.6.2}/pyproject.toml +1 -1
  3. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_grid.py +3 -2
  4. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/downhole_collection.py +303 -303
  5. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/data.py +97 -5
  6. {evo_objects-0.6.1 → evo_objects-0.6.2}/.gitignore +0 -0
  7. {evo_objects-0.6.1 → evo_objects-0.6.2}/LICENSE.md +0 -0
  8. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/__init__.py +0 -0
  9. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/_model_config.py +0 -0
  10. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/__init__.py +0 -0
  11. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/api_client.py +0 -0
  12. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/object_client.py +0 -0
  13. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/parse.py +0 -0
  14. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/data.py +0 -0
  15. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/__init__.py +0 -0
  16. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/__init__.py +0 -0
  17. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/data_api.py +0 -0
  18. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
  19. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/objects_api.py +0 -0
  20. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/stages_api.py +0 -0
  21. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/models.py +0 -0
  22. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/exceptions.py +0 -0
  23. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/io.py +0 -0
  24. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/parquet/__init__.py +0 -0
  25. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/parquet/loader.py +0 -0
  26. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/py.typed +0 -0
  27. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/__init__.py +0 -0
  28. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_data.py +0 -0
  29. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_downhole.py +0 -0
  30. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_model.py +0 -0
  31. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_utils.py +0 -0
  32. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/attributes.py +0 -0
  33. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/base.py +0 -0
  34. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/block_model_ref.py +0 -0
  35. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/downhole_intervals.py +0 -0
  36. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/exceptions.py +0 -0
  37. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/pointset.py +0 -0
  38. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/regular_grid.py +0 -0
  39. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/regular_masked_grid.py +0 -0
  40. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/spatial.py +0 -0
  41. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/tensor_grid.py +0 -0
  42. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/types.py +0 -0
  43. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/variogram.py +0 -0
  44. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/__init__.py +0 -0
  45. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/table_formats.py +0 -0
  46. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/tables.py +0 -0
  47. {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/types.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: evo-objects
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Python SDK for using the Seequent Evo Geoscience Object API
5
5
  Project-URL: Source, https://github.com/SeequentEvo/evo-python-sdk
6
6
  Project-URL: Tracker, https://github.com/SeequentEvo/evo-python-sdk/issues
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "evo-objects"
3
3
  description = "Python SDK for using the Seequent Evo Geoscience Object API"
4
- version = "0.6.1"
4
+ version = "0.6.2"
5
5
  requires-python = ">=3.10"
6
6
  license-files = ["LICENSE.md"]
7
7
  dynamic = ["readme"]
@@ -19,6 +19,7 @@ from typing import Annotated
19
19
  from uuid import UUID
20
20
 
21
21
  import pandas as pd
22
+ from pydantic import AliasChoices, Field
22
23
 
23
24
  from evo.common import IFeedback
24
25
  from evo.common.utils import NoFeedback
@@ -198,8 +199,8 @@ class BlockModelGeometry:
198
199
 
199
200
  model_type: str
200
201
  origin: Point3
201
- n_blocks: Size3i
202
- block_size: Size3d
202
+ n_blocks: Annotated[Size3i, Field(validation_alias=AliasChoices("n_blocks", "n_parent_blocks"))]
203
+ block_size: Annotated[Size3d, Field(validation_alias=AliasChoices("block_size", "parent_block_size"))]
203
204
  rotation: Rotation | None = None
204
205
 
205
206
 
@@ -1,303 +1,303 @@
1
- # Copyright © 2026 Bentley Systems, Incorporated
2
- # Licensed under the Apache License, Version 2.0 (the "License");
3
- # you may not use this file except in compliance with the License.
4
- # You may obtain a copy of the License at
5
- # http://www.apache.org/licenses/LICENSE-2.0
6
- # Unless required by applicable law or agreed to in writing, software
7
- # distributed under the License is distributed on an "AS IS" BASIS,
8
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
- # See the License for the specific language governing permissions and
10
- # limitations under the License.
11
-
12
- from __future__ import annotations
13
-
14
- from dataclasses import dataclass
15
- from typing import Annotated, Any, ClassVar, TypeAlias
16
-
17
- import numpy as np
18
- import pandas as pd
19
- from numpy._typing import NDArray
20
-
21
- from evo.common.interfaces import IContext
22
- from evo.objects import SchemaVersion
23
- from evo.objects.typed._data import DataTable, DataTableAndAttributes
24
- from evo.objects.typed._downhole import HoleIdCategory
25
- from evo.objects.typed._model import DataLocation, SchemaList, SchemaLocation, SchemaModel
26
- from evo.objects.typed.attributes import (
27
- AttributeDescription,
28
- Attributes,
29
- )
30
- from evo.objects.typed.exceptions import ObjectValidationError
31
- from evo.objects.typed.spatial import BaseSpatialObject, BaseSpatialObjectData
32
- from evo.objects.typed.types import BoundingBox
33
- from evo.objects.utils.table_formats import (
34
- DOWNHOLE_COLLECTION_LOCATION_HOLES,
35
- FLOAT_ARRAY_1,
36
- FLOAT_ARRAY_3,
37
- KnownTableFormat,
38
- )
39
-
40
- __all__ = [
41
- "DownholeCollection",
42
- "DownholeCollectionData",
43
- ]
44
-
45
- _X = "x"
46
- _Y = "y"
47
- _Z = "z"
48
- _COORDINATE_COLUMNS = [_X, _Y, _Z]
49
-
50
-
51
- HolePath: TypeAlias = pd.DataFrame # [ distance | dip | azimuth | <attributes> ]
52
- HoleChunks: TypeAlias = pd.DataFrame # [ hole_id | offset | count ]
53
- HoleProperties: TypeAlias = pd.DataFrame # [ hole_id | final | target | current | x | y | z ]
54
- HoleAttributes: TypeAlias = pd.DataFrame
55
-
56
- # If `Depths` has unit descriptions in its `DataFrame.attrs` dictionary, then those units will be used when building
57
- # the schema object.
58
- # This is the expected structure:
59
- # >>> depths_df.attrs
60
- # {'attribute_description': {<column names>: <AttributeDescription>}, ...}
61
- Depths: TypeAlias = pd.DataFrame # [ distance | <attributes> ]
62
-
63
-
64
- @dataclass
65
- class DistanceCollection:
66
- name: str
67
- holes: HoleChunks
68
- distance_table: Depths
69
- collection_type: str = "distance"
70
-
71
-
72
- @dataclass(kw_only=True, frozen=True)
73
- class DownholeCollectionData(BaseSpatialObjectData):
74
- """Data class for creating a new DownholeCollection
75
-
76
- :param name: The name of the object.
77
- :param holes: A DataFrame describing which parts of `path` belong to which holes.
78
- Columns: hole_id, offset, count
79
- :param properties: DataFrame for the properties of the holes. The ith row corresponds to the ith element of `holes`.
80
- Mandatory columns: hole_id, final, target, current, x, y, z
81
- :param attributes: DataFrame for the attributes of the holes. The ith row corresponds to the ith element of `holes`.
82
- :param path: Dataframe of [ distance | dip | azimuth | <attributes> ]. Distance/dip/azimuth describe the geometry as
83
- the step since the previous row.
84
- :param collections: A list of `DistanceCollection` describing a table of distances with attributes.
85
- :param distance_unit: The distance unit for the `path` table and the `properties` x/y/y.
86
- :param desurvey: The desurvey method appropriate for this collection.
87
- Must be one of: "minimum_curvature", "balanced_tangent", "trench".
88
- :param coordinate_reference_system: Optional EPSG code or WKT string for the coordinate reference system.
89
- :param description: Optional description of the object.
90
- :param tags: Optional dictionary of tags for the object.
91
- :param extensions: Optional dictionary of extensions for the object.
92
- """
93
-
94
- path: HolePath
95
- holes: HoleChunks
96
- properties: HoleProperties
97
- attributes: HoleAttributes | None
98
- collections: list[DistanceCollection]
99
- distance_unit: str | None
100
- desurvey: str | None
101
-
102
- def __post_init__(self):
103
- if self.attributes is not None and len(self.holes) != len(self.attributes):
104
- raise ObjectValidationError("The number of attributes rows must match the number or holes rows")
105
-
106
- assert self.attributes is None or len(self.holes) == len(self.attributes)
107
-
108
- def compute_bounding_box(self) -> BoundingBox:
109
- bboxes = []
110
-
111
- for i in range(len(self.holes)):
112
- offset = self.holes.iat[i, 1]
113
- count = self.holes.iat[i, 2]
114
- collar = tuple(self.properties.loc[i, _COORDINATE_COLUMNS])
115
- path_table = self.path[offset : offset + count]
116
- bboxes.append(self._compute_hole_bounding_box(path_table, collar))
117
-
118
- return BoundingBox.combine(bboxes)
119
-
120
- @staticmethod
121
- def _compute_bounding_box_np(
122
- depths: NDArray[np.float64],
123
- dips: NDArray[np.float64],
124
- azimuths: NDArray[np.float64],
125
- offset: tuple[float, float, float] = (0.0, 0.0, 0.0),
126
- ) -> BoundingBox:
127
- if not np.all(depths[:-1] <= depths[1:]):
128
- raise ObjectValidationError("depths must be sorted")
129
-
130
- if len(depths) != len(dips) or len(depths) != len(azimuths):
131
- raise ObjectValidationError("depths, dips, and azimuths must have same length")
132
-
133
- # Process NaNs
134
- # `depths`, `dips`, and `azimuths` could be read-only views, so take copies instead of mutating
135
- depths = depths[~np.isnan(depths)]
136
- dips = np.where(np.isnan(dips), 90.0, dips)
137
- azimuths = np.where(np.isnan(azimuths), 0.0, azimuths)
138
-
139
- dips_rad = np.deg2rad(dips)
140
- azimuths_rad = np.deg2rad(azimuths)
141
-
142
- # Prepend 0 so `step` has the same shape as `dips` and `azimuths`, and so the first depth gets treated as the
143
- # first step. The depth column might already start with 0, in which case the first step will be length 0, which
144
- # is a no-op as far as the following calculation is concerned.
145
- step = np.diff(depths, prepend=0.0)
146
-
147
- dz_down = step * np.sin(dips_rad)
148
- horiz = step * np.cos(dips_rad)
149
-
150
- # Horizontal into N/E (0° = North, 90° = East)
151
- dN = horiz * np.cos(azimuths_rad)
152
- dE = horiz * np.sin(azimuths_rad)
153
-
154
- # Convert to XYZ increments (Z up)
155
- dX = dE
156
- dY = dN
157
- dZ = -dz_down
158
-
159
- x = np.cumsum(dX)
160
- y = np.cumsum(dY)
161
- z = np.cumsum(dZ)
162
-
163
- def ensure_zero(a, b):
164
- return min(a, 0), max(b, 0)
165
-
166
- x0, x1 = ensure_zero(x.min(), x.max())
167
- y0, y1 = ensure_zero(y.min(), y.max())
168
- z0, z1 = ensure_zero(z.min(), z.max())
169
-
170
- return BoundingBox(
171
- min_x=x0 + offset[0],
172
- max_x=x1 + offset[0],
173
- min_y=y0 + offset[1],
174
- max_y=y1 + offset[1],
175
- min_z=z0 + offset[2],
176
- max_z=z1 + offset[2],
177
- )
178
-
179
- @staticmethod
180
- def _compute_hole_bounding_box(
181
- depths_dips_azimuths_table: pd.DataFrame,
182
- collar: tuple[float, float, float],
183
- ) -> BoundingBox:
184
- """
185
- Compute 3D bounding box for a deviated hole given collar XYZ and
186
- depth / dip / azimuth data.
187
-
188
- Conventions
189
- -----------
190
- - depths: measured depth along the hole (m), positive downward.
191
- - dips: inclination FROM VERTICAL (degrees).
192
- 90° = vertical down, 0° = horizontal.
193
- - azimuths: degrees clockwise from North.
194
- - Coordinates: X = Easting, Y = Northing, Z = elevation (up).
195
- """
196
- df = depths_dips_azimuths_table.dropna(subset=["distance"])
197
- box = DownholeCollectionData._compute_bounding_box_np(
198
- df["distance"].astype(float).to_numpy(),
199
- df["dip"].astype(float).to_numpy(),
200
- df["azimuth"].astype(float).to_numpy(),
201
- offset=collar,
202
- )
203
-
204
- return box
205
-
206
-
207
- class HoleChunksTable(DataTable):
208
- table_format: ClassVar[KnownTableFormat] = DOWNHOLE_COLLECTION_LOCATION_HOLES
209
- data_columns: ClassVar[list[str]] = ["hole_index", "offset", "count"]
210
-
211
-
212
- class PathTable(DataTable):
213
- table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
214
- data_columns: ClassVar[list[str]] = ["distance", "azimuth", "dip"]
215
-
216
-
217
- class DownholePath(DataTableAndAttributes):
218
- _table: Annotated[PathTable, SchemaLocation(""), DataLocation("")]
219
-
220
-
221
- class DistancesTable(DataTable):
222
- table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
223
- data_columns: ClassVar[list[str]] = ["final", "target", "current"]
224
-
225
- @classmethod
226
- def _extract_distances(cls, data: HoleAttributes) -> pd.DataFrame:
227
- return data[["final", "target", "current"]].astype(np.float64)
228
-
229
- @classmethod
230
- async def _data_to_schema(cls, data: HoleAttributes, context: IContext) -> Any:
231
- distances_df = cls._extract_distances(data)
232
- return await super()._data_to_schema(distances_df, context)
233
-
234
-
235
- class CollarCoordinates(DataTable):
236
- table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
237
- data_columns: ClassVar[list[str]] = _COORDINATE_COLUMNS
238
-
239
- @classmethod
240
- def _extract_coordinates(cls, data: HoleAttributes):
241
- return data[["x", "y", "z"]].astype(np.float64)
242
-
243
- @classmethod
244
- async def _data_to_schema(cls, data: HoleAttributes, context: IContext) -> Any:
245
- distances_df = cls._extract_coordinates(data)
246
- return await super()._data_to_schema(distances_df, context)
247
-
248
-
249
- class DownholeLocation(SchemaModel):
250
- hole_id: Annotated[HoleIdCategory, SchemaLocation("hole_id"), DataLocation("properties")]
251
- path: Annotated[DownholePath, SchemaLocation("path"), DataLocation("path")]
252
- holes: Annotated[HoleChunksTable, SchemaLocation("holes"), DataLocation("holes")]
253
- distances: Annotated[DistancesTable, SchemaLocation("distances"), DataLocation("properties")]
254
- coordinates: Annotated[CollarCoordinates, SchemaLocation("coordinates"), DataLocation("properties")]
255
- attributes: Annotated[Attributes, SchemaLocation("attributes"), DataLocation("attributes")]
256
-
257
-
258
- class _Distances(DataTable):
259
- table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_1
260
- data_columns: ClassVar[list[str]] = ["distance"]
261
-
262
-
263
- class DistanceTableDistances(DataTableAndAttributes):
264
- _table: Annotated[_Distances, SchemaLocation("values"), DataLocation("")]
265
- unit: Annotated[str | None, SchemaLocation("unit")]
266
-
267
- @classmethod
268
- async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
269
- result = await super()._data_to_schema(data, context)
270
- attr_desc: AttributeDescription = data.attrs.get("attribute_descriptions", {}).get("distance")
271
- if attr_desc is not None and attr_desc.unit is not None:
272
- # "unit" can be missing, but it must not be `None`
273
- result["unit"] = attr_desc.unit
274
- return result
275
-
276
-
277
- class DistanceTable(SchemaModel):
278
- name: Annotated[str, SchemaLocation("name"), DataLocation("name")]
279
- collection_type: Annotated[str, SchemaLocation("collection_type"), DataLocation("collection_type")]
280
- distance: Annotated[DistanceTableDistances, SchemaLocation("distance"), DataLocation("distance_table")]
281
-
282
-
283
- class DownholeDistanceTable(DistanceTable):
284
- holes: Annotated[HoleChunksTable, SchemaLocation("holes"), DataLocation("holes")]
285
-
286
-
287
- class DownholeCollectionTables(SchemaList[DownholeDistanceTable]):
288
- pass
289
-
290
-
291
- class DownholeCollection(BaseSpatialObject):
292
- """A GeoscienceObject representing a collection of downholes."""
293
-
294
- _data_class = DownholeCollectionData
295
- sub_classification = "downhole-collection"
296
- creation_schema_version = SchemaVersion(major=1, minor=3, patch=1)
297
-
298
- location: Annotated[DownholeLocation, SchemaLocation("location"), DataLocation("")]
299
- collections: Annotated[DownholeCollectionTables, SchemaLocation("collections"), DataLocation("collections")]
300
- distance_unit: Annotated[str | None, SchemaLocation("distance_unit")]
301
- desurvey: Annotated[str | None, SchemaLocation("desurvey")]
302
-
303
- type: ClassVar[Annotated[str, SchemaLocation("type")]] = "downhole"
1
+ # Copyright © 2026 Bentley Systems, Incorporated
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Annotated, Any, ClassVar, TypeAlias
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ from numpy._typing import NDArray
20
+
21
+ from evo.common.interfaces import IContext
22
+ from evo.objects import SchemaVersion
23
+ from evo.objects.typed._data import DataTable, DataTableAndAttributes
24
+ from evo.objects.typed._downhole import HoleIdCategory
25
+ from evo.objects.typed._model import DataLocation, SchemaList, SchemaLocation, SchemaModel
26
+ from evo.objects.typed.attributes import (
27
+ AttributeDescription,
28
+ Attributes,
29
+ )
30
+ from evo.objects.typed.exceptions import ObjectValidationError
31
+ from evo.objects.typed.spatial import BaseSpatialObject, BaseSpatialObjectData
32
+ from evo.objects.typed.types import BoundingBox
33
+ from evo.objects.utils.table_formats import (
34
+ DOWNHOLE_COLLECTION_LOCATION_HOLES,
35
+ FLOAT_ARRAY_1,
36
+ FLOAT_ARRAY_3,
37
+ KnownTableFormat,
38
+ )
39
+
40
+ __all__ = [
41
+ "DownholeCollection",
42
+ "DownholeCollectionData",
43
+ ]
44
+
45
+ _X = "x"
46
+ _Y = "y"
47
+ _Z = "z"
48
+ _COORDINATE_COLUMNS = [_X, _Y, _Z]
49
+
50
+
51
+ HolePath: TypeAlias = pd.DataFrame # [ distance | dip | azimuth | <attributes> ]
52
+ HoleChunks: TypeAlias = pd.DataFrame # [ hole_id | offset | count ]
53
+ HoleProperties: TypeAlias = pd.DataFrame # [ hole_id | final | target | current | x | y | z ]
54
+ HoleAttributes: TypeAlias = pd.DataFrame
55
+
56
+ # If `Depths` has unit descriptions in its `DataFrame.attrs` dictionary, then those units will be used when building
57
+ # the schema object.
58
+ # This is the expected structure:
59
+ # >>> depths_df.attrs
60
+ # {'attribute_description': {<column names>: <AttributeDescription>}, ...}
61
+ Depths: TypeAlias = pd.DataFrame # [ distance | <attributes> ]
62
+
63
+
64
+ @dataclass
65
+ class DistanceCollection:
66
+ name: str
67
+ holes: HoleChunks
68
+ distance_table: Depths
69
+ collection_type: str = "distance"
70
+
71
+
72
+ @dataclass(kw_only=True, frozen=True)
73
+ class DownholeCollectionData(BaseSpatialObjectData):
74
+ """Data class for creating a new DownholeCollection
75
+
76
+ :param name: The name of the object.
77
+ :param holes: A DataFrame describing which parts of `path` belong to which holes.
78
+ Columns: hole_id, offset, count
79
+ :param properties: DataFrame for the properties of the holes. The ith row corresponds to the ith element of `holes`.
80
+ Mandatory columns: hole_id, final, target, current, x, y, z
81
+ :param attributes: DataFrame for the attributes of the holes. The ith row corresponds to the ith element of `holes`.
82
+ :param path: Dataframe of [ distance | dip | azimuth | <attributes> ]. Distance/dip/azimuth describe the geometry as
83
+ the step since the previous row.
84
+ :param collections: A list of `DistanceCollection` describing a table of distances with attributes.
85
+ :param distance_unit: The distance unit for the `path` table and the `properties` x/y/y.
86
+ :param desurvey: The desurvey method appropriate for this collection.
87
+ Must be one of: "minimum_curvature", "balanced_tangent", "trench".
88
+ :param coordinate_reference_system: Optional EPSG code or WKT string for the coordinate reference system.
89
+ :param description: Optional description of the object.
90
+ :param tags: Optional dictionary of tags for the object.
91
+ :param extensions: Optional dictionary of extensions for the object.
92
+ """
93
+
94
+ path: HolePath
95
+ holes: HoleChunks
96
+ properties: HoleProperties
97
+ attributes: HoleAttributes | None
98
+ collections: list[DistanceCollection]
99
+ distance_unit: str | None
100
+ desurvey: str | None
101
+
102
+ def __post_init__(self):
103
+ if self.attributes is not None and len(self.holes) != len(self.attributes):
104
+ raise ObjectValidationError("The number of attributes rows must match the number or holes rows")
105
+
106
+ assert self.attributes is None or len(self.holes) == len(self.attributes)
107
+
108
+ def compute_bounding_box(self) -> BoundingBox:
109
+ bboxes = []
110
+
111
+ for i in range(len(self.holes)):
112
+ offset = self.holes.iat[i, 1]
113
+ count = self.holes.iat[i, 2]
114
+ collar = tuple(self.properties.loc[i, _COORDINATE_COLUMNS])
115
+ path_table = self.path[offset : offset + count]
116
+ bboxes.append(self._compute_hole_bounding_box(path_table, collar))
117
+
118
+ return BoundingBox.combine(bboxes)
119
+
120
+ @staticmethod
121
+ def _compute_bounding_box_np(
122
+ depths: NDArray[np.float64],
123
+ dips: NDArray[np.float64],
124
+ azimuths: NDArray[np.float64],
125
+ offset: tuple[float, float, float] = (0.0, 0.0, 0.0),
126
+ ) -> BoundingBox:
127
+ if not np.all(depths[:-1] <= depths[1:]):
128
+ raise ObjectValidationError("depths must be sorted")
129
+
130
+ if len(depths) != len(dips) or len(depths) != len(azimuths):
131
+ raise ObjectValidationError("depths, dips, and azimuths must have same length")
132
+
133
+ # Process NaNs
134
+ # `depths`, `dips`, and `azimuths` could be read-only views, so take copies instead of mutating
135
+ depths = depths[~np.isnan(depths)]
136
+ dips = np.where(np.isnan(dips), 90.0, dips)
137
+ azimuths = np.where(np.isnan(azimuths), 0.0, azimuths)
138
+
139
+ dips_rad = np.deg2rad(dips)
140
+ azimuths_rad = np.deg2rad(azimuths)
141
+
142
+ # Prepend 0 so `step` has the same shape as `dips` and `azimuths`, and so the first depth gets treated as the
143
+ # first step. The depth column might already start with 0, in which case the first step will be length 0, which
144
+ # is a no-op as far as the following calculation is concerned.
145
+ step = np.diff(depths, prepend=0.0)
146
+
147
+ dz_down = step * np.sin(dips_rad)
148
+ horiz = step * np.cos(dips_rad)
149
+
150
+ # Horizontal into N/E (0° = North, 90° = East)
151
+ dN = horiz * np.cos(azimuths_rad)
152
+ dE = horiz * np.sin(azimuths_rad)
153
+
154
+ # Convert to XYZ increments (Z up)
155
+ dX = dE
156
+ dY = dN
157
+ dZ = -dz_down
158
+
159
+ x = np.cumsum(dX)
160
+ y = np.cumsum(dY)
161
+ z = np.cumsum(dZ)
162
+
163
+ def ensure_zero(a, b):
164
+ return min(a, 0), max(b, 0)
165
+
166
+ x0, x1 = ensure_zero(x.min(), x.max())
167
+ y0, y1 = ensure_zero(y.min(), y.max())
168
+ z0, z1 = ensure_zero(z.min(), z.max())
169
+
170
+ return BoundingBox(
171
+ min_x=x0 + offset[0],
172
+ max_x=x1 + offset[0],
173
+ min_y=y0 + offset[1],
174
+ max_y=y1 + offset[1],
175
+ min_z=z0 + offset[2],
176
+ max_z=z1 + offset[2],
177
+ )
178
+
179
+ @staticmethod
180
+ def _compute_hole_bounding_box(
181
+ depths_dips_azimuths_table: pd.DataFrame,
182
+ collar: tuple[float, float, float],
183
+ ) -> BoundingBox:
184
+ """
185
+ Compute 3D bounding box for a deviated hole given collar XYZ and
186
+ depth / dip / azimuth data.
187
+
188
+ Conventions
189
+ -----------
190
+ - depths: measured depth along the hole (m), positive downward.
191
+ - dips: inclination FROM VERTICAL (degrees).
192
+ 90° = vertical down, 0° = horizontal.
193
+ - azimuths: degrees clockwise from North.
194
+ - Coordinates: X = Easting, Y = Northing, Z = elevation (up).
195
+ """
196
+ df = depths_dips_azimuths_table.dropna(subset=["distance"])
197
+ box = DownholeCollectionData._compute_bounding_box_np(
198
+ df["distance"].astype(float).to_numpy(),
199
+ df["dip"].astype(float).to_numpy(),
200
+ df["azimuth"].astype(float).to_numpy(),
201
+ offset=collar,
202
+ )
203
+
204
+ return box
205
+
206
+
207
+ class HoleChunksTable(DataTable):
208
+ table_format: ClassVar[KnownTableFormat] = DOWNHOLE_COLLECTION_LOCATION_HOLES
209
+ data_columns: ClassVar[list[str]] = ["hole_index", "offset", "count"]
210
+
211
+
212
+ class PathTable(DataTable):
213
+ table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
214
+ data_columns: ClassVar[list[str]] = ["distance", "azimuth", "dip"]
215
+
216
+
217
+ class DownholePath(DataTableAndAttributes):
218
+ _table: Annotated[PathTable, SchemaLocation(""), DataLocation("")]
219
+
220
+
221
+ class DistancesTable(DataTable):
222
+ table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
223
+ data_columns: ClassVar[list[str]] = ["final", "target", "current"]
224
+
225
+ @classmethod
226
+ def _extract_distances(cls, data: HoleAttributes) -> pd.DataFrame:
227
+ return data[["final", "target", "current"]].astype(np.float64)
228
+
229
+ @classmethod
230
+ async def _data_to_schema(cls, data: HoleAttributes, context: IContext) -> Any:
231
+ distances_df = cls._extract_distances(data)
232
+ return await super()._data_to_schema(distances_df, context)
233
+
234
+
235
+ class CollarCoordinates(DataTable):
236
+ table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
237
+ data_columns: ClassVar[list[str]] = _COORDINATE_COLUMNS
238
+
239
+ @classmethod
240
+ def _extract_coordinates(cls, data: HoleAttributes):
241
+ return data[["x", "y", "z"]].astype(np.float64)
242
+
243
+ @classmethod
244
+ async def _data_to_schema(cls, data: HoleAttributes, context: IContext) -> Any:
245
+ distances_df = cls._extract_coordinates(data)
246
+ return await super()._data_to_schema(distances_df, context)
247
+
248
+
249
+ class DownholeLocation(SchemaModel):
250
+ hole_id: Annotated[HoleIdCategory, SchemaLocation("hole_id"), DataLocation("properties")]
251
+ path: Annotated[DownholePath, SchemaLocation("path"), DataLocation("path")]
252
+ holes: Annotated[HoleChunksTable, SchemaLocation("holes"), DataLocation("holes")]
253
+ distances: Annotated[DistancesTable, SchemaLocation("distances"), DataLocation("properties")]
254
+ coordinates: Annotated[CollarCoordinates, SchemaLocation("coordinates"), DataLocation("properties")]
255
+ attributes: Annotated[Attributes, SchemaLocation("attributes"), DataLocation("attributes")]
256
+
257
+
258
+ class _Distances(DataTable):
259
+ table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_1
260
+ data_columns: ClassVar[list[str]] = ["distance"]
261
+
262
+
263
+ class DistanceTableDistances(DataTableAndAttributes):
264
+ _table: Annotated[_Distances, SchemaLocation("values"), DataLocation("")]
265
+ unit: Annotated[str | None, SchemaLocation("unit")]
266
+
267
+ @classmethod
268
+ async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
269
+ result = await super()._data_to_schema(data, context)
270
+ attr_desc: AttributeDescription = data.attrs.get("attribute_descriptions", {}).get("distance")
271
+ if attr_desc is not None and attr_desc.unit is not None:
272
+ # "unit" can be missing, but it must not be `None`
273
+ result["unit"] = attr_desc.unit
274
+ return result
275
+
276
+
277
+ class DistanceTable(SchemaModel):
278
+ name: Annotated[str, SchemaLocation("name"), DataLocation("name")]
279
+ collection_type: Annotated[str, SchemaLocation("collection_type"), DataLocation("collection_type")]
280
+ distance: Annotated[DistanceTableDistances, SchemaLocation("distance"), DataLocation("distance_table")]
281
+
282
+
283
+ class DownholeDistanceTable(DistanceTable):
284
+ holes: Annotated[HoleChunksTable, SchemaLocation("holes"), DataLocation("holes")]
285
+
286
+
287
+ class DownholeCollectionTables(SchemaList[DownholeDistanceTable]):
288
+ pass
289
+
290
+
291
+ class DownholeCollection(BaseSpatialObject):
292
+ """A GeoscienceObject representing a collection of downholes."""
293
+
294
+ _data_class = DownholeCollectionData
295
+ sub_classification = "downhole-collection"
296
+ creation_schema_version = SchemaVersion(major=1, minor=3, patch=1)
297
+
298
+ location: Annotated[DownholeLocation, SchemaLocation("location"), DataLocation("")]
299
+ collections: Annotated[DownholeCollectionTables, SchemaLocation("collections"), DataLocation("collections")]
300
+ distance_unit: Annotated[str | None, SchemaLocation("distance_unit")]
301
+ desurvey: Annotated[str | None, SchemaLocation("desurvey")]
302
+
303
+ type: ClassVar[Annotated[str, SchemaLocation("type")]] = "downhole"
@@ -9,7 +9,8 @@
9
9
  # See the License for the specific language governing permissions and
10
10
  # limitations under the License.
11
11
 
12
- from collections.abc import Iterator
12
+ import asyncio
13
+ from collections.abc import Iterator, Sequence
13
14
  from pathlib import Path
14
15
  from typing import Any, Iterable, cast
15
16
  from uuid import UUID
@@ -23,7 +24,7 @@ from evo.common.io.exceptions import DataExistsError
23
24
  from evo.common.utils import NoFeedback, PartialFeedback, split_feedback
24
25
 
25
26
  from ..exceptions import TableFormatError
26
- from ..io import _CACHE_SCOPE, ObjectDataUpload
27
+ from ..io import _CACHE_SCOPE, ObjectDataDownload, ObjectDataUpload
27
28
  from .table_formats import INTEGER_ARRAY_1_INT32, INTEGER_ARRAY_MD_INT32, LOOKUP_TABLE_INT32
28
29
  from .tables import KnownTableFormat
29
30
  from .types import ArrayTableInfo, CategoryInfo, LookupTableInfo
@@ -46,6 +47,7 @@ __all__ = ["ObjectDataClient"]
46
47
  logger = logging.getLogger("object.data")
47
48
 
48
49
  _DATA_ID_KEY = "data" # The key used to identify data references in geoscience objects.
50
+ _DEFAULT_MAX_CONCURRENCY = 4 # Decent performance improvement going from 1 to 4 but beyond that not so much
49
51
 
50
52
 
51
53
  def _iter_refs(target: Any, _key: str | None = None) -> Iterator[str]:
@@ -287,20 +289,110 @@ class ObjectDataClient:
287
289
  :raises TableFormatError: If the data does not match the expected format.
288
290
  :raises SchemaValidationError: If the data has a different number of rows than expected.
289
291
  """
292
+ data_id = str(table_info["data"])
293
+ downloads = await self._prepare_data_downloads(object_id, version_id, [data_id])
294
+ return await self._download_prepared_table(downloads[data_id], table_info, fb)
295
+
296
+ async def _prepare_data_downloads(
297
+ self, object_id: UUID, version_id: str, data_identifiers: Sequence[str | UUID]
298
+ ) -> dict[str, ObjectDataDownload]:
299
+ """Prepare download contexts for multiple data files with one object metadata request.
300
+
301
+ :param object_id: The object ID to download data from.
302
+ :param version_id: The version ID of the object to download data from.
303
+ :param data_identifiers: The data IDs to download.
304
+
305
+ :return: A mapping of data IDs to prepared download contexts.
306
+
307
+ :raises DataNotFoundError: If a data ID is not associated with this object version.
308
+ """
290
309
  # Import here to avoid circular import.
291
310
  from ..client import ObjectAPIClient
292
- from ..parquet import ParquetDownloader
311
+
312
+ if len(data_identifiers) == 0:
313
+ return {}
314
+
315
+ data_ids = list({str(data_id) for data_id in data_identifiers})
293
316
 
294
317
  client = ObjectAPIClient(self._environment, self._connector)
295
- (download,) = [d async for d in client.prepare_data_download(object_id, version_id, [table_info["data"]])]
318
+ downloads = [download async for download in client.prepare_data_download(object_id, version_id, data_ids)]
319
+ return {download.name: download for download in downloads}
320
+
321
+ async def _download_prepared_table(self, download: ObjectDataDownload, table_info: dict, fb: IFeedback) -> pa.Table:
322
+ """Download and load one table using a prepared download context.
323
+
324
+ :param download: The prepared context for the table's data.
325
+ :param table_info: The table info that defines the expected format.
326
+ :param fb: A feedback object for tracking download progress.
327
+
328
+ :return: A pyarrow table loaded directly from the parquet file.
329
+ """
330
+ # Import here to avoid circular import.
331
+ from ..parquet import ParquetDownloader
296
332
 
297
- # Defer downloading the table to the new ParquetLoader class.
298
333
  async with ParquetDownloader(
299
334
  download=download, transport=self._connector.transport, cache=self._cache
300
335
  ).with_feedback(fb) as loader:
301
336
  loader.validate_with_table_info(table_info)
302
337
  return loader.load_as_table()
303
338
 
339
+ async def download_tables(
340
+ self,
341
+ object_id: UUID,
342
+ version_id: str,
343
+ table_infos: Sequence[dict],
344
+ fb: IFeedback = NoFeedback,
345
+ max_concurrency: int = _DEFAULT_MAX_CONCURRENCY,
346
+ ) -> dict[str, pa.Table]:
347
+ """Download multiple pyarrow tables with one object metadata request.
348
+
349
+ The data references are prepared once and the table downloads are performed concurrently, up to
350
+ ``max_concurrency`` at a time. Duplicate data references are downloaded only once.
351
+
352
+ :param object_id: The object ID to download data from.
353
+ :param version_id: The version ID of the object to download data from.
354
+ :param table_infos: The table information that defines the expected format of each table.
355
+ :param fb: A feedback object for tracking download progress.
356
+ :param max_concurrency: The maximum number of tables to download concurrently.
357
+
358
+ :return: A mapping of data IDs to loaded pyarrow tables.
359
+
360
+ :raises ValueError: If ``max_concurrency`` is less than one.
361
+ :raises DataNotFoundError: If a data ID is not associated with this object version.
362
+ :raises TableFormatError: If a table does not match its expected format.
363
+ :raises SchemaValidationError: If a table has a different number of rows than expected.
364
+ """
365
+ if max_concurrency < 1:
366
+ raise ValueError("max_concurrency must be at least 1")
367
+
368
+ table_infos_by_data_id: dict[str, dict] = {str(table_info["data"]): table_info for table_info in table_infos}
369
+
370
+ if len(table_infos_by_data_id) == 0:
371
+ return {}
372
+
373
+ data_ids = list(table_infos_by_data_id)
374
+ downloads = await self._prepare_data_downloads(object_id, version_id, data_ids)
375
+ feedbacks = split_feedback(fb, [1.0] * len(data_ids))
376
+ semaphore = asyncio.Semaphore(max_concurrency)
377
+
378
+ async def download_one(data_id: str, table_info: dict, table_fb: IFeedback) -> pa.Table:
379
+ async with semaphore:
380
+ return await self._download_prepared_table(downloads[data_id], table_info, table_fb)
381
+
382
+ tasks = {
383
+ data_id: asyncio.create_task(download_one(data_id, table_info, table_fb))
384
+ for (data_id, table_info), table_fb in zip(table_infos_by_data_id.items(), feedbacks)
385
+ }
386
+ try:
387
+ await asyncio.gather(*tasks.values())
388
+ except BaseException:
389
+ # asyncio.gather doesn't cancel sibling tasks if one fails, manually cancel other tasks if one fails
390
+ for task in tasks.values():
391
+ task.cancel()
392
+ await asyncio.gather(*tasks.values(), return_exceptions=True)
393
+ raise
394
+ return {data_id: task.result() for data_id, task in tasks.items()}
395
+
304
396
  if _PD_AVAILABLE:
305
397
  # Optional support for pandas dataframes. Depends on both pyarrow and pandas.
306
398
 
File without changes
File without changes