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.
- {evo_objects-0.6.1 → evo_objects-0.6.2}/PKG-INFO +2 -2
- {evo_objects-0.6.1 → evo_objects-0.6.2}/pyproject.toml +1 -1
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_grid.py +3 -2
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/downhole_collection.py +303 -303
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/data.py +97 -5
- {evo_objects-0.6.1 → evo_objects-0.6.2}/.gitignore +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/LICENSE.md +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/_model_config.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/api_client.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/object_client.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/client/parse.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/data.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/data_api.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/objects_api.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/api/stages_api.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/endpoints/models.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/exceptions.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/io.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/parquet/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/parquet/loader.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/py.typed +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_data.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_downhole.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_model.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/_utils.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/attributes.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/base.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/block_model_ref.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/downhole_intervals.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/exceptions.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/pointset.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/regular_grid.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/regular_masked_grid.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/spatial.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/tensor_grid.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/types.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/typed/variogram.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/__init__.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/table_formats.py +0 -0
- {evo_objects-0.6.1 → evo_objects-0.6.2}/src/evo/objects/utils/tables.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: evo-objects
|
|
3
|
-
Version: 0.6.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|