evo-objects 0.4.3__tar.gz → 0.6.1__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.4.3 → evo_objects-0.6.1}/PKG-INFO +2 -2
- {evo_objects-0.4.3 → evo_objects-0.6.1}/pyproject.toml +2 -2
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/__init__.py +7 -1
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_data.py +15 -2
- evo_objects-0.6.1/src/evo/objects/typed/_downhole.py +62 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_model.py +68 -21
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/attributes.py +78 -5
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/base.py +2 -25
- evo_objects-0.6.1/src/evo/objects/typed/downhole_collection.py +303 -0
- evo_objects-0.6.1/src/evo/objects/typed/downhole_intervals.py +246 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/spatial.py +2 -8
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/types.py +24 -41
- {evo_objects-0.4.3 → evo_objects-0.6.1}/.gitignore +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/LICENSE.md +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/_model_config.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/api_client.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/object_client.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/parse.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/data.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/data_api.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/objects_api.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/stages_api.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/models.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/exceptions.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/io.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/parquet/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/parquet/loader.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/py.typed +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_grid.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_utils.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/block_model_ref.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/exceptions.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/pointset.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/regular_grid.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/regular_masked_grid.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/tensor_grid.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/variogram.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/__init__.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/data.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/table_formats.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/tables.py +0 -0
- {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evo-objects
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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
|
|
@@ -9,7 +9,7 @@ Project-URL: Documentation, https://developer.seequent.com/
|
|
|
9
9
|
Author-email: Seequent <support@seequent.com>
|
|
10
10
|
License-File: LICENSE.md
|
|
11
11
|
Requires-Python: >=3.10
|
|
12
|
-
Requires-Dist: evo-sdk-common[jmespath]>=0.5.
|
|
12
|
+
Requires-Dist: evo-sdk-common[jmespath]>=0.5.26
|
|
13
13
|
Requires-Dist: pydantic<3,>=2
|
|
14
14
|
Provides-Extra: aiohttp
|
|
15
15
|
Requires-Dist: evo-sdk-common[aiohttp]; extra == 'aiohttp'
|
|
@@ -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.
|
|
4
|
+
version = "0.6.1"
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
license-files = ["LICENSE.md"]
|
|
7
7
|
dynamic = ["readme"]
|
|
@@ -10,7 +10,7 @@ authors = [
|
|
|
10
10
|
]
|
|
11
11
|
|
|
12
12
|
dependencies = [
|
|
13
|
-
"evo-sdk-common[jmespath]>=0.5.
|
|
13
|
+
"evo-sdk-common[jmespath]>=0.5.26",
|
|
14
14
|
"pydantic>=2,<3",
|
|
15
15
|
]
|
|
16
16
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright ©
|
|
1
|
+
# Copyright © 2026 Bentley Systems, Incorporated
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -22,6 +22,8 @@ from .base import BaseObject, object_from_path, object_from_reference, object_fr
|
|
|
22
22
|
from .block_model_ref import (
|
|
23
23
|
BlockModel,
|
|
24
24
|
)
|
|
25
|
+
from .downhole_collection import DownholeCollection, DownholeCollectionData
|
|
26
|
+
from .downhole_intervals import DownholeIntervals, DownholeIntervalsData
|
|
25
27
|
from .pointset import (
|
|
26
28
|
Locations,
|
|
27
29
|
PointSet,
|
|
@@ -80,6 +82,10 @@ __all__ = [
|
|
|
80
82
|
"BoundingBox",
|
|
81
83
|
"CoordinateReferenceSystem",
|
|
82
84
|
"CubicStructure",
|
|
85
|
+
"DownholeCollection",
|
|
86
|
+
"DownholeCollectionData",
|
|
87
|
+
"DownholeIntervals",
|
|
88
|
+
"DownholeIntervalsData",
|
|
83
89
|
"Ellipsoid",
|
|
84
90
|
"EllipsoidRanges",
|
|
85
91
|
"EpsgCode",
|
|
@@ -1,3 +1,14 @@
|
|
|
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
|
+
|
|
1
12
|
from __future__ import annotations
|
|
2
13
|
|
|
3
14
|
from typing import Annotated, Any, ClassVar
|
|
@@ -111,7 +122,7 @@ class DataTableAndAttributes(SchemaModel):
|
|
|
111
122
|
if attr_df is not None:
|
|
112
123
|
await self.attributes.set_attributes(attr_df, fb=fb)
|
|
113
124
|
else:
|
|
114
|
-
|
|
125
|
+
self.attributes.clear()
|
|
115
126
|
|
|
116
127
|
def validate(self) -> None:
|
|
117
128
|
"""Validate that all attributes have the correct length."""
|
|
@@ -143,4 +154,6 @@ class DataTableAndAttributes(SchemaModel):
|
|
|
143
154
|
builder = SchemaBuilder(cls, context)
|
|
144
155
|
await builder.set_sub_model_value("_table", table_df)
|
|
145
156
|
await builder.set_sub_model_value("attributes", attr_df)
|
|
146
|
-
|
|
157
|
+
|
|
158
|
+
# As we specially handle the _table and attributes sub-models above, we skip them in the default data processing of build_from_data.
|
|
159
|
+
return await builder.build_from_data(data, skip_sub_models={"_table", "attributes"})
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
"""Shared sub-models for downhole typed objects."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Annotated, Any, ClassVar
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from evo.common.interfaces import IContext
|
|
21
|
+
from evo.objects.utils.table_formats import FLOAT_ARRAY_2, KnownTableFormat
|
|
22
|
+
|
|
23
|
+
from ._data import DataTable
|
|
24
|
+
from ._model import DataLocation, SchemaLocation, SchemaModel
|
|
25
|
+
from .attributes import Category
|
|
26
|
+
|
|
27
|
+
_HOLE_ID_COL = "hole_id"
|
|
28
|
+
_FROM_COL = "from"
|
|
29
|
+
_TO_COL = "to"
|
|
30
|
+
_DEPTH_COLS: list[str] = [_FROM_COL, _TO_COL]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HoleIdCategory(Category):
|
|
34
|
+
"""Categorical hole identifier column, shared by downhole typed objects."""
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
|
|
38
|
+
category_table = data[[_HOLE_ID_COL]].astype("category")
|
|
39
|
+
return await super()._data_to_schema(category_table, context=context)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DepthIntervalsTable(DataTable):
|
|
43
|
+
table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_2
|
|
44
|
+
data_columns: ClassVar[list[str]] = _DEPTH_COLS
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
|
|
48
|
+
return await super()._data_to_schema(data[_DEPTH_COLS], context)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FromToModel(SchemaModel):
|
|
52
|
+
"""Schema model for the `from_to` component of a downhole intervals object."""
|
|
53
|
+
|
|
54
|
+
intervals: Annotated[DepthIntervalsTable, SchemaLocation("intervals.start_and_end"), DataLocation("intervals")]
|
|
55
|
+
unit: Annotated[str | None, SchemaLocation("unit"), DataLocation("depth_unit")]
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
async def _data_to_schema(cls, data: Any, context: IContext) -> Any:
|
|
59
|
+
result = await super()._data_to_schema(data, context)
|
|
60
|
+
if data.depth_unit is not None:
|
|
61
|
+
result["unit"] = data.depth_unit
|
|
62
|
+
return result
|
|
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import copy
|
|
15
15
|
from collections.abc import Sequence
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
|
-
from typing import Annotated, Any, Callable, Generic, TypeVar, get_args, get_origin, get_type_hints, overload
|
|
17
|
+
from typing import Annotated, Any, Callable, ClassVar, Generic, TypeVar, get_args, get_origin, get_type_hints, overload
|
|
18
18
|
|
|
19
19
|
from pydantic import TypeAdapter
|
|
20
20
|
|
|
@@ -86,11 +86,8 @@ class SubModelMetadata:
|
|
|
86
86
|
"""The field name in the data class for this sub-model."""
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
class
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
This can be used on either typed objects classes or dataset classes.
|
|
93
|
-
"""
|
|
89
|
+
class BaseSchemaProperty(Generic[_T]):
|
|
90
|
+
"""Base descriptor for reading data from a Geoscience Object schema."""
|
|
94
91
|
|
|
95
92
|
def __init__(
|
|
96
93
|
self,
|
|
@@ -111,7 +108,7 @@ class SchemaProperty(Generic[_T]):
|
|
|
111
108
|
return self._type_adapter.dump_python(value)
|
|
112
109
|
|
|
113
110
|
@overload
|
|
114
|
-
def __get__(self, instance: None, owner: type[SchemaModel]) ->
|
|
111
|
+
def __get__(self, instance: None, owner: type[SchemaModel]) -> BaseSchemaProperty[_T]: ...
|
|
115
112
|
|
|
116
113
|
@overload
|
|
117
114
|
def __get__(self, instance: SchemaModel, owner: type[SchemaModel]) -> _T: ...
|
|
@@ -128,6 +125,13 @@ class SchemaProperty(Generic[_T]):
|
|
|
128
125
|
# Use TypeAdapter to validate and apply defaults from Field annotations
|
|
129
126
|
return self._type_adapter.validate_python(value)
|
|
130
127
|
|
|
128
|
+
|
|
129
|
+
class SchemaProperty(BaseSchemaProperty[_T]):
|
|
130
|
+
"""Descriptor for mutable data within a Geoscience Object schema.
|
|
131
|
+
|
|
132
|
+
This can be used on either typed objects classes or dataset classes.
|
|
133
|
+
"""
|
|
134
|
+
|
|
131
135
|
def __set__(self, instance: SchemaModel, value: Any) -> None:
|
|
132
136
|
_set_property_value(self, instance._document, value)
|
|
133
137
|
|
|
@@ -140,7 +144,19 @@ class SchemaProperty(Generic[_T]):
|
|
|
140
144
|
_set_property_value(self, document, value)
|
|
141
145
|
|
|
142
146
|
|
|
143
|
-
|
|
147
|
+
class SchemaConstantProperty(BaseSchemaProperty[_T]):
|
|
148
|
+
"""A SchemaProperty that always writes a fixed value to the schema."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, jmespath_expr: str, type_adapter: TypeAdapter[_T], value: _T) -> None:
|
|
151
|
+
super().__init__(jmespath_expr=jmespath_expr, type_adapter=type_adapter)
|
|
152
|
+
self._value = value
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def value(self) -> _T:
|
|
156
|
+
return self._value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _set_property_value(schema_property: BaseSchemaProperty, document: dict[str, Any], value: Any) -> None:
|
|
144
160
|
"""Set the value of a SchemaProperty by name.
|
|
145
161
|
|
|
146
162
|
:param property_name: The name of the property to set.
|
|
@@ -177,6 +193,26 @@ class SchemaBuilder:
|
|
|
177
193
|
else:
|
|
178
194
|
self.document.update(sub_document)
|
|
179
195
|
|
|
196
|
+
async def build_from_data(self, data: Any, skip_sub_models: set[str] | None = None) -> dict[str, Any]:
|
|
197
|
+
"""Build the schema document by applying all properties and sub-models."""
|
|
198
|
+
for key, prop in self._properties.items():
|
|
199
|
+
if isinstance(prop, SchemaConstantProperty):
|
|
200
|
+
self.set_property(key, prop.value)
|
|
201
|
+
else:
|
|
202
|
+
value = getattr(data, key, None)
|
|
203
|
+
self.set_property(key, value)
|
|
204
|
+
|
|
205
|
+
for name, metadata in self._sub_models.items():
|
|
206
|
+
if skip_sub_models and name in skip_sub_models:
|
|
207
|
+
continue
|
|
208
|
+
if metadata.data_field:
|
|
209
|
+
sub_data = getattr(data, metadata.data_field, None)
|
|
210
|
+
else:
|
|
211
|
+
sub_data = data
|
|
212
|
+
await self.set_sub_model_value(name, sub_data)
|
|
213
|
+
|
|
214
|
+
return self.document
|
|
215
|
+
|
|
180
216
|
|
|
181
217
|
def _get_base_type(annotation: Any) -> tuple[Any, SchemaLocation | None, DataLocation | None]:
|
|
182
218
|
"""Extract the base type and SchemaLocation from an annotation.
|
|
@@ -208,14 +244,14 @@ class SchemaModel:
|
|
|
208
244
|
automatically created for nested SchemaModel/SchemaList fields.
|
|
209
245
|
"""
|
|
210
246
|
|
|
211
|
-
_schema_properties: dict[str,
|
|
247
|
+
_schema_properties: dict[str, BaseSchemaProperty[Any]] = {}
|
|
212
248
|
_sub_models: dict[str, SubModelMetadata] = {}
|
|
213
249
|
|
|
214
250
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
215
251
|
super().__init_subclass__(**kwargs)
|
|
216
252
|
|
|
217
253
|
# Initialize with inherited values (copy to avoid mutating parent)
|
|
218
|
-
schema_properties: dict[str,
|
|
254
|
+
schema_properties: dict[str, BaseSchemaProperty[Any]] = {}
|
|
219
255
|
sub_models: dict[str, SubModelMetadata] = {}
|
|
220
256
|
for base in cls.__mro__[1:]:
|
|
221
257
|
if issubclass(base, SchemaModel):
|
|
@@ -232,8 +268,28 @@ class SchemaModel:
|
|
|
232
268
|
cls._sub_models = sub_models
|
|
233
269
|
return
|
|
234
270
|
|
|
235
|
-
# Process the resolved annotations
|
|
271
|
+
# Process the resolved annotations, to produce the final set of schema properties and sub-models for this class
|
|
236
272
|
for field_name, annotation in hints.items():
|
|
273
|
+
# Handle ClassVar fields
|
|
274
|
+
if get_origin(annotation) is ClassVar:
|
|
275
|
+
inner_args = get_args(annotation)
|
|
276
|
+
inner_type = inner_args[0]
|
|
277
|
+
base_type, schema_location, _ = _get_base_type(inner_type)
|
|
278
|
+
# Skip class variables without a SchemaLocation, e.g. data_columns: ClassVar[list[str]] = [],
|
|
279
|
+
if schema_location is None:
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
# Treat such remaining ClassVar fields as constants, which also have a corresponding value in the schema
|
|
283
|
+
type_adapter = TypeAdapter(base_type)
|
|
284
|
+
prop = SchemaConstantProperty(
|
|
285
|
+
jmespath_expr=schema_location.jmespath_expr,
|
|
286
|
+
type_adapter=type_adapter,
|
|
287
|
+
value=getattr(cls, field_name),
|
|
288
|
+
)
|
|
289
|
+
setattr(cls, field_name, prop)
|
|
290
|
+
schema_properties[field_name] = prop
|
|
291
|
+
continue
|
|
292
|
+
|
|
237
293
|
base_type, schema_location, data_location = _get_base_type(annotation)
|
|
238
294
|
|
|
239
295
|
# Skip fields without a SchemaLocation
|
|
@@ -348,16 +404,7 @@ class SchemaModel:
|
|
|
348
404
|
:return: The dictionary representation of the data.
|
|
349
405
|
"""
|
|
350
406
|
builder = SchemaBuilder(cls, context)
|
|
351
|
-
|
|
352
|
-
value = getattr(data, key, None)
|
|
353
|
-
builder.set_property(key, value)
|
|
354
|
-
for name, metadata in cls._sub_models.items():
|
|
355
|
-
if metadata.data_field:
|
|
356
|
-
sub_data = getattr(data, metadata.data_field, None)
|
|
357
|
-
else:
|
|
358
|
-
sub_data = data
|
|
359
|
-
await builder.set_sub_model_value(name, sub_data)
|
|
360
|
-
return builder.document
|
|
407
|
+
return await builder.build_from_data(data)
|
|
361
408
|
|
|
362
409
|
def search(self, expression: str) -> Any:
|
|
363
410
|
"""Search the model using a JMESPath expression.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright ©
|
|
1
|
+
# Copyright © 2026 Bentley Systems, Incorporated
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import typing
|
|
14
15
|
import uuid
|
|
16
|
+
from dataclasses import dataclass
|
|
15
17
|
from typing import TYPE_CHECKING, Annotated, Any
|
|
16
18
|
from uuid import UUID
|
|
17
19
|
|
|
@@ -23,6 +25,7 @@ from evo.common.utils import NoFeedback, iter_with_fb
|
|
|
23
25
|
from evo.objects import DownloadedObject
|
|
24
26
|
from evo.objects.utils.table_formats import (
|
|
25
27
|
BOOL_ARRAY_1,
|
|
28
|
+
DATE_TIME_ARRAY,
|
|
26
29
|
FLOAT_ARRAY_1,
|
|
27
30
|
INTEGER_ARRAY_1_INT32,
|
|
28
31
|
INTEGER_ARRAY_1_INT64,
|
|
@@ -67,6 +70,8 @@ def _infer_attribute_type_from_series(series: pd.Series) -> str:
|
|
|
67
70
|
return "category"
|
|
68
71
|
elif pd.api.types.is_string_dtype(series):
|
|
69
72
|
return "string"
|
|
73
|
+
elif pd.api.types.infer_dtype(series, skipna=True) in ["date", "datetime", "datetime64"]:
|
|
74
|
+
return "date_time"
|
|
70
75
|
else:
|
|
71
76
|
raise UnSupportedDataTypeError(f"Unsupported dtype for attribute: {series.dtype}")
|
|
72
77
|
|
|
@@ -76,9 +81,35 @@ _attribute_table_formats = {
|
|
|
76
81
|
"integer": [INTEGER_ARRAY_1_INT32, INTEGER_ARRAY_1_INT64],
|
|
77
82
|
"bool": [BOOL_ARRAY_1],
|
|
78
83
|
"string": [STRING_ARRAY],
|
|
84
|
+
"date_time": [DATE_TIME_ARRAY],
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
|
|
88
|
+
@dataclass
|
|
89
|
+
class AttributeDescription:
|
|
90
|
+
discipline: str = ""
|
|
91
|
+
type: str = ""
|
|
92
|
+
unit: str | None = None
|
|
93
|
+
scale: str | None = None
|
|
94
|
+
extensions: dict[str, typing.Any] | None = None
|
|
95
|
+
tags: dict[str, str] | None = None
|
|
96
|
+
|
|
97
|
+
def to_schema(self):
|
|
98
|
+
result = {
|
|
99
|
+
"discipline": self.discipline,
|
|
100
|
+
"type": self.type,
|
|
101
|
+
}
|
|
102
|
+
if self.unit:
|
|
103
|
+
result["unit"] = self.unit
|
|
104
|
+
if self.scale:
|
|
105
|
+
result["scale"] = self.scale
|
|
106
|
+
if self.extensions:
|
|
107
|
+
result["extensions"] = self.extensions
|
|
108
|
+
if self.tags:
|
|
109
|
+
result["tags"] = self.tags
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
82
113
|
class Attribute(SchemaModel):
|
|
83
114
|
"""A Geoscience Object Attribute"""
|
|
84
115
|
|
|
@@ -158,7 +189,7 @@ class Attribute(SchemaModel):
|
|
|
158
189
|
table_formats = _attribute_table_formats.get(attribute_type)
|
|
159
190
|
attr_doc["values"] = await data_client.upload_dataframe(df, table_format=table_formats, fb=fb)
|
|
160
191
|
|
|
161
|
-
if attribute_type in ["scalar", "integer", "category"]:
|
|
192
|
+
if attribute_type in ["scalar", "integer", "category", "date_time"]:
|
|
162
193
|
attr_doc["nan_description"] = {"values": []}
|
|
163
194
|
|
|
164
195
|
|
|
@@ -203,7 +234,15 @@ class PendingAttribute:
|
|
|
203
234
|
|
|
204
235
|
|
|
205
236
|
class Attributes(SchemaList[Attribute]):
|
|
206
|
-
"""A collection of Geoscience Object Attributes
|
|
237
|
+
"""A collection of Geoscience Object Attributes
|
|
238
|
+
|
|
239
|
+
The data source is a pandas DataFrame. If the DataFrame has attribute descriptions attached
|
|
240
|
+
to it, then they will be used to populate the schema's "attribute_description" fields. The
|
|
241
|
+
attribute descriptions are attached to the DataFrame's `attrs` attribute.
|
|
242
|
+
|
|
243
|
+
>>> df.attrs
|
|
244
|
+
{'attribute_description': {<column names>: <AttributeDescription>}, ...}
|
|
245
|
+
"""
|
|
207
246
|
|
|
208
247
|
_schema_path: str | None = None
|
|
209
248
|
"""The full JMESPath to this attributes list within the parent object schema."""
|
|
@@ -263,7 +302,7 @@ class Attributes(SchemaList[Attribute]):
|
|
|
263
302
|
|
|
264
303
|
for col in df.columns:
|
|
265
304
|
series = df[col]
|
|
266
|
-
attribute_type = _infer_attribute_type_from_series(
|
|
305
|
+
attribute_type = _infer_attribute_type_from_series(df[col])
|
|
267
306
|
|
|
268
307
|
attr_doc: dict[str, Any] = {
|
|
269
308
|
"name": str(col),
|
|
@@ -271,9 +310,19 @@ class Attributes(SchemaList[Attribute]):
|
|
|
271
310
|
"attribute_type": attribute_type,
|
|
272
311
|
}
|
|
273
312
|
|
|
274
|
-
|
|
313
|
+
if attribute_type == "date_time":
|
|
314
|
+
series = pd.to_datetime(series, utc=True).dt.as_unit("us")
|
|
315
|
+
|
|
316
|
+
col_df = pd.DataFrame({col: series})
|
|
275
317
|
await Attribute._upload_attribute_values(attr_doc, col_df, attribute_type, data_client)
|
|
276
318
|
|
|
319
|
+
attr_desc: AttributeDescription | None = df.attrs.get("attribute_descriptions", {}).get(col)
|
|
320
|
+
if attr_desc is not None:
|
|
321
|
+
if not isinstance(attr_desc, AttributeDescription):
|
|
322
|
+
raise TypeError("attribute description must be a AttributeDescription.")
|
|
323
|
+
if attr_desc.unit is not None:
|
|
324
|
+
attr_doc["attribute_description"] = attr_desc.to_schema()
|
|
325
|
+
|
|
277
326
|
attributes_list.append(attr_doc)
|
|
278
327
|
|
|
279
328
|
async def to_dataframe(self, *keys: str, fb: IFeedback = NoFeedback) -> pd.DataFrame:
|
|
@@ -528,3 +577,27 @@ class BlockModelAttributes:
|
|
|
528
577
|
def __repr__(self) -> str:
|
|
529
578
|
names = [attr.name for attr in self._attributes]
|
|
530
579
|
return f"BlockModelAttributes({names})"
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class Category(SchemaModel):
|
|
583
|
+
_data: Annotated[str, SchemaLocation("values.data")]
|
|
584
|
+
_length: Annotated[int, SchemaLocation("values.length")]
|
|
585
|
+
|
|
586
|
+
@property
|
|
587
|
+
def length(self):
|
|
588
|
+
return self._length
|
|
589
|
+
|
|
590
|
+
@classmethod
|
|
591
|
+
async def _data_to_schema(cls, category_table, context: IContext) -> Any:
|
|
592
|
+
data_client = get_data_client(context)
|
|
593
|
+
return await data_client.upload_category_dataframe(category_table)
|
|
594
|
+
|
|
595
|
+
async def to_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame:
|
|
596
|
+
"""Load a DataFrame containing values for this table.
|
|
597
|
+
|
|
598
|
+
:param fb: Optional feedback object to report download progress.
|
|
599
|
+
:return: The loaded DataFrame with values for this table.
|
|
600
|
+
"""
|
|
601
|
+
if self._context.is_data_modified(self._data):
|
|
602
|
+
raise DataLoaderError("Data was modified since the object was downloaded")
|
|
603
|
+
return await self._obj.download_category_dataframe(self.as_dict(), fb=fb)
|
|
@@ -227,31 +227,8 @@ class _BaseObject(SchemaModel):
|
|
|
227
227
|
"sub_classification and creation_schema_version must be defined by the subclass"
|
|
228
228
|
)
|
|
229
229
|
schema_id = ObjectSchema("objects", cls.sub_classification, cls.creation_schema_version)
|
|
230
|
-
result
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
# Handle schema properties
|
|
235
|
-
for key, prop in cls._schema_properties.items():
|
|
236
|
-
value = getattr(data, key, None)
|
|
237
|
-
if value is not None:
|
|
238
|
-
prop.apply_to(result, value)
|
|
239
|
-
|
|
240
|
-
# Handle annotation-based sub-models
|
|
241
|
-
for name, metadata in cls._sub_models.items():
|
|
242
|
-
if metadata.data_field:
|
|
243
|
-
sub_data = getattr(data, metadata.data_field, None)
|
|
244
|
-
else:
|
|
245
|
-
sub_data = data
|
|
246
|
-
if sub_data is not None:
|
|
247
|
-
from ._utils import assign_jmespath_value
|
|
248
|
-
|
|
249
|
-
sub_document = await metadata.model_type._data_to_schema(sub_data, context)
|
|
250
|
-
if metadata.jmespath_expr:
|
|
251
|
-
assign_jmespath_value(result, metadata.jmespath_expr, sub_document)
|
|
252
|
-
else:
|
|
253
|
-
result.update(sub_document)
|
|
254
|
-
|
|
230
|
+
result = await super()._data_to_schema(data, context)
|
|
231
|
+
result["schema"] = str(schema_id)
|
|
255
232
|
return result
|
|
256
233
|
|
|
257
234
|
@classmethod
|
|
@@ -0,0 +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"
|
|
@@ -0,0 +1,246 @@
|
|
|
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
|
+
"""Typed access for downhole-intervals objects."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Annotated, Any, ClassVar
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from evo.common import IContext, IFeedback
|
|
22
|
+
from evo.common.utils import NoFeedback
|
|
23
|
+
from evo.objects import SchemaVersion
|
|
24
|
+
from evo.objects.utils.table_formats import FLOAT_ARRAY_3
|
|
25
|
+
|
|
26
|
+
from ._data import DataTable
|
|
27
|
+
from ._downhole import FromToModel, HoleIdCategory
|
|
28
|
+
from ._model import DataLocation, SchemaLocation
|
|
29
|
+
from .attributes import Attributes
|
|
30
|
+
from .exceptions import ObjectValidationError
|
|
31
|
+
from .spatial import BaseSpatialObject, BaseSpatialObjectData
|
|
32
|
+
from .types import BoundingBox
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DownholeIntervals",
|
|
36
|
+
"DownholeIntervalsData",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_HOLE_ID_COL = "hole_id"
|
|
41
|
+
_FROM_COL = "from"
|
|
42
|
+
_TO_COL = "to"
|
|
43
|
+
_START_COLS: list[str] = ["x_start", "y_start", "z_start"]
|
|
44
|
+
_END_COLS: list[str] = ["x_end", "y_end", "z_end"]
|
|
45
|
+
_MID_COLS: list[str] = ["x_mid", "y_mid", "z_mid"]
|
|
46
|
+
|
|
47
|
+
_ALL_REQUIRED_COLS: frozenset[str] = frozenset([_HOLE_ID_COL, _FROM_COL, _TO_COL] + _START_COLS + _END_COLS + _MID_COLS)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(kw_only=True, frozen=True)
|
|
51
|
+
class DownholeIntervalsData(BaseSpatialObjectData):
|
|
52
|
+
"""Data for creating a new DownholeIntervals object.
|
|
53
|
+
|
|
54
|
+
:param name: The name of the object.
|
|
55
|
+
:param intervals: DataFrame containing the interval data. Required columns:
|
|
56
|
+
|
|
57
|
+
* `hole_id` — hole identifier (string or Categorical)
|
|
58
|
+
* `from` — depth of the top of the interval
|
|
59
|
+
* `to` — depth of the base of the interval
|
|
60
|
+
* `x_start`, `y_start`, `z_start` — 3D start-point coordinates
|
|
61
|
+
* `x_end`, `y_end`, `z_end` — 3D end-point coordinates
|
|
62
|
+
* `x_mid`, `y_mid`, `z_mid` — 3D mid-point coordinates
|
|
63
|
+
|
|
64
|
+
Any additional columns are uploaded as interval attributes. Per-attribute
|
|
65
|
+
units can be specified via the DataFrame's `attrs` dictionary using
|
|
66
|
+
:class:`~evo.objects.typed.attributes.AttributeDescription`::
|
|
67
|
+
|
|
68
|
+
from evo.objects.typed.attributes import AttributeDescription
|
|
69
|
+
df.attrs["attribute_descriptions"] = {"grade": AttributeDescription(unit="ppm")}
|
|
70
|
+
|
|
71
|
+
:param is_composited: Whether the intervals have been composited.
|
|
72
|
+
:param depth_unit: Optional unit identifier for the from/to depths (e.g. `"m"`).
|
|
73
|
+
:param coordinate_reference_system: Optional EPSG code or OGC WKT string for the CRS.
|
|
74
|
+
:param description: Optional description of the object.
|
|
75
|
+
:param tags: Optional dictionary of tags for the object.
|
|
76
|
+
:param extensions: Optional dictionary of extensions for the object.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
intervals: pd.DataFrame
|
|
80
|
+
is_composited: bool
|
|
81
|
+
depth_unit: str | None = None
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
missing = _ALL_REQUIRED_COLS - set(self.intervals.columns)
|
|
85
|
+
if missing:
|
|
86
|
+
raise ObjectValidationError(f"intervals DataFrame is missing required columns: {sorted(missing)}")
|
|
87
|
+
|
|
88
|
+
def compute_bounding_box(self) -> BoundingBox:
|
|
89
|
+
"""Compute the bounding box from all start, end, and mid-point coordinates."""
|
|
90
|
+
df = self.intervals
|
|
91
|
+
all_x = pd.concat([df["x_start"], df["x_end"], df["x_mid"]])
|
|
92
|
+
all_y = pd.concat([df["y_start"], df["y_end"], df["y_mid"]])
|
|
93
|
+
all_z = pd.concat([df["z_start"], df["z_end"], df["z_mid"]])
|
|
94
|
+
return BoundingBox.from_points(all_x.values, all_y.values, all_z.values)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class StartCoordTable(DataTable):
|
|
98
|
+
table_format: ClassVar = FLOAT_ARRAY_3
|
|
99
|
+
data_columns: ClassVar[list[str]] = _START_COLS
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
|
|
103
|
+
return await super()._data_to_schema(data[_START_COLS], context)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class EndCoordTable(DataTable):
|
|
107
|
+
table_format: ClassVar = FLOAT_ARRAY_3
|
|
108
|
+
data_columns: ClassVar[list[str]] = _END_COLS
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
|
|
112
|
+
return await super()._data_to_schema(data[_END_COLS], context)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MidCoordTable(DataTable):
|
|
116
|
+
table_format: ClassVar = FLOAT_ARRAY_3
|
|
117
|
+
data_columns: ClassVar[list[str]] = _MID_COLS
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
async def _data_to_schema(cls, data: pd.DataFrame, context: IContext) -> Any:
|
|
121
|
+
return await super()._data_to_schema(data[_MID_COLS], context)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class IntervalAttributes(Attributes):
|
|
125
|
+
"""Attributes sub-model that filters out required columns before upload."""
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
async def _data_to_schema(cls, data: Any, context: IContext) -> list[dict[str, Any]]:
|
|
129
|
+
if data is not None:
|
|
130
|
+
attr_cols = [c for c in data.columns if c not in _ALL_REQUIRED_COLS]
|
|
131
|
+
data = data[attr_cols] if attr_cols else None
|
|
132
|
+
return await super()._data_to_schema(data, context)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DownholeIntervals(BaseSpatialObject):
|
|
136
|
+
"""A GeoscienceObject representing downhole intervals.
|
|
137
|
+
|
|
138
|
+
Downhole intervals describe depth-ranged samples along drill holes. Each
|
|
139
|
+
interval is defined by a hole identifier, a from/to depth range, and the
|
|
140
|
+
3D coordinates of the interval's start, end, and mid-point. Optional
|
|
141
|
+
attributes (assay values, lithology codes, etc.) may also be attached.
|
|
142
|
+
|
|
143
|
+
Example usage::
|
|
144
|
+
|
|
145
|
+
import pandas as pd
|
|
146
|
+
from evo.objects.typed import DownholeIntervals, DownholeIntervalsData
|
|
147
|
+
|
|
148
|
+
df = pd.DataFrame(
|
|
149
|
+
{
|
|
150
|
+
"hole_id": pd.Categorical(["DH001", "DH001", "DH002"]),
|
|
151
|
+
"from": [0.0, 5.0, 0.0],
|
|
152
|
+
"to": [5.0, 10.0, 3.0],
|
|
153
|
+
"x_start": [100.0, 100.0, 200.0],
|
|
154
|
+
"y_start": [200.0, 200.0, 300.0],
|
|
155
|
+
"z_start": [ 0.0, -5.0, 0.0],
|
|
156
|
+
"x_end": [100.0, 100.0, 200.0],
|
|
157
|
+
"y_end": [200.0, 200.0, 300.0],
|
|
158
|
+
"z_end": [ -5.0, -10.0, -3.0],
|
|
159
|
+
"x_mid": [100.0, 100.0, 200.0],
|
|
160
|
+
"y_mid": [200.0, 200.0, 300.0],
|
|
161
|
+
"z_mid": [ -2.5, -7.5, -1.5],
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
data = DownholeIntervalsData(
|
|
165
|
+
name="My Downhole Intervals",
|
|
166
|
+
intervals=df,
|
|
167
|
+
is_composited=False,
|
|
168
|
+
depth_unit="m",
|
|
169
|
+
)
|
|
170
|
+
obj = await DownholeIntervals.create(context, data)
|
|
171
|
+
|
|
172
|
+
# Download all data as a single DataFrame
|
|
173
|
+
df = await obj.to_dataframe()
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
_data_class = DownholeIntervalsData
|
|
177
|
+
|
|
178
|
+
sub_classification = "downhole-intervals"
|
|
179
|
+
creation_schema_version = SchemaVersion(major=1, minor=3, patch=0)
|
|
180
|
+
|
|
181
|
+
is_composited: Annotated[bool, SchemaLocation("is_composited")]
|
|
182
|
+
|
|
183
|
+
start: Annotated[StartCoordTable, SchemaLocation("start.coordinates"), DataLocation("intervals")]
|
|
184
|
+
end: Annotated[EndCoordTable, SchemaLocation("end.coordinates"), DataLocation("intervals")]
|
|
185
|
+
mid_points: Annotated[MidCoordTable, SchemaLocation("mid_points.coordinates"), DataLocation("intervals")]
|
|
186
|
+
from_to: Annotated[FromToModel, SchemaLocation("from_to")]
|
|
187
|
+
hole_id: Annotated[HoleIdCategory, SchemaLocation("hole_id"), DataLocation("intervals")]
|
|
188
|
+
attributes: Annotated[IntervalAttributes, SchemaLocation("attributes"), DataLocation("intervals")]
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def depth_unit(self) -> str | None:
|
|
192
|
+
"""The unit of the from/to depths, or None if not specified."""
|
|
193
|
+
return self.from_to.unit
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def num_intervals(self) -> int:
|
|
197
|
+
"""The number of intervals in this object."""
|
|
198
|
+
return self.from_to.intervals.length
|
|
199
|
+
|
|
200
|
+
async def to_dataframe(self, *keys: str, fb: IFeedback = NoFeedback) -> pd.DataFrame:
|
|
201
|
+
"""Get all interval data as a single DataFrame.
|
|
202
|
+
|
|
203
|
+
The returned DataFrame has the following columns, in order:
|
|
204
|
+
|
|
205
|
+
* `hole_id` — hole identifier
|
|
206
|
+
* `from`, `to` — depth interval
|
|
207
|
+
* `x_start`, `y_start`, `z_start` — start-point coordinates
|
|
208
|
+
* `x_end`, `y_end`, `z_end` — end-point coordinates
|
|
209
|
+
* `x_mid`, `y_mid`, `z_mid` — mid-point coordinates
|
|
210
|
+
* Any attribute columns
|
|
211
|
+
|
|
212
|
+
:param keys: Optional attribute keys/names to include. If omitted, all
|
|
213
|
+
attributes are included.
|
|
214
|
+
:param fb: Optional feedback object to report download progress.
|
|
215
|
+
:return: A combined DataFrame of all interval data and attributes.
|
|
216
|
+
"""
|
|
217
|
+
hole_id_df = await self.hole_id.to_dataframe(fb=fb)
|
|
218
|
+
depth_df = await self.from_to.intervals.to_dataframe(fb=fb)
|
|
219
|
+
start_df = await self.start.to_dataframe(fb=fb)
|
|
220
|
+
end_df = await self.end.to_dataframe(fb=fb)
|
|
221
|
+
mid_df = await self.mid_points.to_dataframe(fb=fb)
|
|
222
|
+
|
|
223
|
+
parts: list[pd.DataFrame] = [hole_id_df, depth_df, start_df, end_df, mid_df]
|
|
224
|
+
|
|
225
|
+
if len(self.attributes) > 0:
|
|
226
|
+
attr_df = await self.attributes.to_dataframe(*keys, fb=fb)
|
|
227
|
+
parts.append(attr_df)
|
|
228
|
+
|
|
229
|
+
return pd.concat(parts, axis=1)
|
|
230
|
+
|
|
231
|
+
def validate(self) -> None:
|
|
232
|
+
"""Validate the object, checking that all interval tables have consistent lengths."""
|
|
233
|
+
super().validate()
|
|
234
|
+
expected_length = self.num_intervals
|
|
235
|
+
for table_name, table in (
|
|
236
|
+
("hole_id", self.hole_id),
|
|
237
|
+
("start.coordinates", self.start),
|
|
238
|
+
("end.coordinates", self.end),
|
|
239
|
+
("mid_points.coordinates", self.mid_points),
|
|
240
|
+
):
|
|
241
|
+
if table.length != expected_length:
|
|
242
|
+
raise ObjectValidationError(
|
|
243
|
+
f"{table_name} length ({table.length}) does not match expected length ({expected_length})"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
self.attributes.validate_lengths(expected_length)
|
|
@@ -48,6 +48,7 @@ class BaseSpatialObject(BaseObject):
|
|
|
48
48
|
"""Base class for all Geoscience Objects with spatial data."""
|
|
49
49
|
|
|
50
50
|
_bbox_type_adapter: ClassVar[TypeAdapter[BoundingBox]] = TypeAdapter(BoundingBox)
|
|
51
|
+
_crs_type_adapter: ClassVar[TypeAdapter[CoordinateReferenceSystem]] = TypeAdapter(CoordinateReferenceSystem)
|
|
51
52
|
_bounding_box: Annotated[BoundingBox, SchemaLocation("bounding_box")]
|
|
52
53
|
coordinate_reference_system: Annotated[CoordinateReferenceSystem, SchemaLocation("coordinate_reference_system")]
|
|
53
54
|
|
|
@@ -56,14 +57,7 @@ class BaseSpatialObject(BaseObject):
|
|
|
56
57
|
"""Create a object dictionary suitable for creating a new Geoscience Object."""
|
|
57
58
|
object_dict = await super()._data_to_schema(data, context)
|
|
58
59
|
object_dict["bounding_box"] = cls._bbox_type_adapter.dump_python(data.compute_bounding_box())
|
|
59
|
-
|
|
60
|
-
crs = data.coordinate_reference_system
|
|
61
|
-
if crs is None:
|
|
62
|
-
object_dict["coordinate_reference_system"] = "unspecified"
|
|
63
|
-
elif isinstance(crs, int):
|
|
64
|
-
object_dict["coordinate_reference_system"] = {"epsg_code": crs}
|
|
65
|
-
else:
|
|
66
|
-
object_dict["coordinate_reference_system"] = crs
|
|
60
|
+
object_dict["coordinate_reference_system"] = cls._crs_type_adapter.dump_python(data.coordinate_reference_system)
|
|
67
61
|
return object_dict
|
|
68
62
|
|
|
69
63
|
# The bounding box is defined as regular a property so that subclasses can override it if needed
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Copyright ©
|
|
1
|
+
# Copyright © 2026 Bentley Systems, Incorporated
|
|
2
2
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
3
|
# you may not use this file except in compliance with the License.
|
|
4
4
|
# You may obtain a copy of the License at
|
|
@@ -17,9 +17,9 @@ from typing import Annotated, Any, overload
|
|
|
17
17
|
import numpy as np
|
|
18
18
|
import numpy.typing as npt
|
|
19
19
|
import pydantic
|
|
20
|
-
from pydantic_core import core_schema
|
|
21
20
|
|
|
22
21
|
# Import basic geometry types from evo.common and re-export
|
|
22
|
+
from evo.common.crs import EpsgCode, parse_crs
|
|
23
23
|
from evo.common.typed import Point3, Size3d, Size3i
|
|
24
24
|
|
|
25
25
|
__all__ = [
|
|
@@ -35,36 +35,6 @@ __all__ = [
|
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
class EpsgCode(int):
|
|
39
|
-
"""An integer representing an EPSG code."""
|
|
40
|
-
|
|
41
|
-
def __new__(cls, value: int | str) -> EpsgCode:
|
|
42
|
-
if isinstance(value, str):
|
|
43
|
-
try:
|
|
44
|
-
value = int(value)
|
|
45
|
-
except ValueError as ve:
|
|
46
|
-
raise ValueError(f"Cannot convert '{value}' to an integer EPSG code") from ve
|
|
47
|
-
|
|
48
|
-
if not (1024 <= value <= 32767):
|
|
49
|
-
raise ValueError(f"EPSG code must be between 1024 and 32767, got {value}")
|
|
50
|
-
|
|
51
|
-
return int.__new__(cls, value)
|
|
52
|
-
|
|
53
|
-
def __repr__(self) -> str:
|
|
54
|
-
return f"EpsgCode({int(self)})"
|
|
55
|
-
|
|
56
|
-
def __str__(self):
|
|
57
|
-
return f"EPSG:{int(self)}"
|
|
58
|
-
|
|
59
|
-
@classmethod
|
|
60
|
-
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
|
|
61
|
-
return core_schema.no_info_after_validator_function(
|
|
62
|
-
cls,
|
|
63
|
-
core_schema.int_schema(),
|
|
64
|
-
serialization=core_schema.plain_serializer_function_ser_schema(int),
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
38
|
def _dump_crs(value: EpsgCode | str | None) -> Any:
|
|
69
39
|
if value is None:
|
|
70
40
|
return "unspecified"
|
|
@@ -78,17 +48,16 @@ def _dump_crs(value: EpsgCode | str | None) -> Any:
|
|
|
78
48
|
|
|
79
49
|
def _load_crs(value: Any) -> EpsgCode | str | None:
|
|
80
50
|
if value == "unspecified":
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
if "epsg_code" in value:
|
|
84
|
-
return EpsgCode(value["epsg_code"])
|
|
85
|
-
elif "ogc_wkt" in value:
|
|
86
|
-
return value["ogc_wkt"]
|
|
87
|
-
else:
|
|
88
|
-
raise ValueError("Invalid CRS dictionary format")
|
|
89
|
-
else:
|
|
51
|
+
return parse_crs(value)
|
|
52
|
+
if not isinstance(value, dict):
|
|
90
53
|
raise ValueError("Invalid CRS format")
|
|
91
54
|
|
|
55
|
+
if set(value) == {"epsg_code"}:
|
|
56
|
+
return parse_crs(value["epsg_code"])
|
|
57
|
+
if set(value) == {"ogc_wkt"} and isinstance(value["ogc_wkt"], str):
|
|
58
|
+
return value["ogc_wkt"]
|
|
59
|
+
raise ValueError("Invalid CRS dictionary format")
|
|
60
|
+
|
|
92
61
|
|
|
93
62
|
CoordinateReferenceSystem = Annotated[
|
|
94
63
|
EpsgCode | str | None, pydantic.PlainValidator(_load_crs), pydantic.PlainSerializer(_dump_crs)
|
|
@@ -218,6 +187,20 @@ class BoundingBox:
|
|
|
218
187
|
)
|
|
219
188
|
return cls.from_extent(origin, extent, rotation)
|
|
220
189
|
|
|
190
|
+
@classmethod
|
|
191
|
+
def combine(cls, bboxes: list[BoundingBox]):
|
|
192
|
+
if not bboxes:
|
|
193
|
+
raise ValueError("bboxes list is empty")
|
|
194
|
+
|
|
195
|
+
return cls(
|
|
196
|
+
min_x=min(b.min_x for b in bboxes),
|
|
197
|
+
max_x=max(b.max_x for b in bboxes),
|
|
198
|
+
min_y=min(b.min_y for b in bboxes),
|
|
199
|
+
max_y=max(b.max_y for b in bboxes),
|
|
200
|
+
min_z=min(b.min_z for b in bboxes),
|
|
201
|
+
max_z=max(b.max_z for b in bboxes),
|
|
202
|
+
)
|
|
203
|
+
|
|
221
204
|
|
|
222
205
|
@dataclass(frozen=True)
|
|
223
206
|
class Rotation:
|
|
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
|