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.
Files changed (47) hide show
  1. {evo_objects-0.4.3 → evo_objects-0.6.1}/PKG-INFO +2 -2
  2. {evo_objects-0.4.3 → evo_objects-0.6.1}/pyproject.toml +2 -2
  3. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/__init__.py +7 -1
  4. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_data.py +15 -2
  5. evo_objects-0.6.1/src/evo/objects/typed/_downhole.py +62 -0
  6. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_model.py +68 -21
  7. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/attributes.py +78 -5
  8. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/base.py +2 -25
  9. evo_objects-0.6.1/src/evo/objects/typed/downhole_collection.py +303 -0
  10. evo_objects-0.6.1/src/evo/objects/typed/downhole_intervals.py +246 -0
  11. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/spatial.py +2 -8
  12. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/types.py +24 -41
  13. {evo_objects-0.4.3 → evo_objects-0.6.1}/.gitignore +0 -0
  14. {evo_objects-0.4.3 → evo_objects-0.6.1}/LICENSE.md +0 -0
  15. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/__init__.py +0 -0
  16. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/_model_config.py +0 -0
  17. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/__init__.py +0 -0
  18. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/api_client.py +0 -0
  19. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/object_client.py +0 -0
  20. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/client/parse.py +0 -0
  21. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/data.py +0 -0
  22. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/__init__.py +0 -0
  23. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/__init__.py +0 -0
  24. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/data_api.py +0 -0
  25. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
  26. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/objects_api.py +0 -0
  27. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/api/stages_api.py +0 -0
  28. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/endpoints/models.py +0 -0
  29. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/exceptions.py +0 -0
  30. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/io.py +0 -0
  31. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/parquet/__init__.py +0 -0
  32. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/parquet/loader.py +0 -0
  33. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/py.typed +0 -0
  34. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_grid.py +0 -0
  35. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/_utils.py +0 -0
  36. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/block_model_ref.py +0 -0
  37. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/exceptions.py +0 -0
  38. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/pointset.py +0 -0
  39. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/regular_grid.py +0 -0
  40. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/regular_masked_grid.py +0 -0
  41. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/tensor_grid.py +0 -0
  42. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/typed/variogram.py +0 -0
  43. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/__init__.py +0 -0
  44. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/data.py +0 -0
  45. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/table_formats.py +0 -0
  46. {evo_objects-0.4.3 → evo_objects-0.6.1}/src/evo/objects/utils/tables.py +0 -0
  47. {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.4.3
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.8
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.3"
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.8",
13
+ "evo-sdk-common[jmespath]>=0.5.26",
14
14
  "pydantic>=2,<3",
15
15
  ]
16
16
 
@@ -1,4 +1,4 @@
1
- # Copyright © 2025 Bentley Systems, Incorporated
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
- await self.attributes.clear()
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
- return builder.document
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 SchemaProperty(Generic[_T]):
90
- """Descriptor for data within a Geoscience Object schema.
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]) -> SchemaProperty[_T]: ...
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
- def _set_property_value(schema_property: SchemaProperty, document: dict[str, Any], value: Any) -> None:
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, SchemaProperty[Any]] = {}
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, SchemaProperty[Any]] = {}
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
- for key in cls._schema_properties.keys():
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 © 2025 Bentley Systems, Incorporated
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(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
- col_df = df[[col]]
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: dict[str, Any] = {
231
- "schema": str(schema_id),
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
- # Always set coordinate_reference_system, defaulting to "unspecified" if None
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 © 2025 Bentley Systems, Incorporated
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 None
82
- elif isinstance(value, dict):
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