Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
532621f
feat(objects): add interval and indexed downhole collections
sergiopeixoto-seequent Jul 31, 2026
9808cd9
feat(objects): add typed object prefetch support
sergiopeixoto-seequent Jul 31, 2026
aec5e9e
feat(objects): add typed attribute metadata
sergiopeixoto-seequent Jul 31, 2026
1053f66
feat(objects): expose data client context (bump evo-objects to 0.6.2)
sergiopeixoto-seequent Jul 31, 2026
798b0fc
feat(objects): refine typed model and downhole collection; add prefet…
sergiopeixoto-seequent Aug 5, 2026
c9c4561
feat(objects): enhance collection handling and add new tests for attr…
sergiopeixoto-seequent Aug 5, 2026
8ecca39
feat(objects-tests): update categorical data generation and improve t…
sergiopeixoto-seequent Aug 5, 2026
2a8a2d2
feat(objects-tests): add unit precedence and attribute type validatio…
sergiopeixoto-seequent Aug 5, 2026
28a2f44
Merge branch 'main' into AD2052323-dhc-intervals
sergiopeixoto-seequent Aug 6, 2026
045ae5b
chore(objects-test): update license information in test_prefetch.py
sergiopeixoto-seequent Aug 6, 2026
fba1243
feat(objects): update AttributeDescription to use default empty strings
sergiopeixoto-seequent Aug 10, 2026
dc4ee30
refactor(objects): unify table attribute naming in DistanceCollection…
sergiopeixoto-seequent Aug 10, 2026
2dbb04b
feat(objects): Keep dense zero-based categorical codes as the SDK wri…
sergiopeixoto-seequent Aug 10, 2026
73945ff
feat(objects): Require explicit nullable domain choices for units and…
sergiopeixoto-seequent Aug 10, 2026
0efb006
feat(objects): enhance to_dataframe methods to accept attribute selec…
sergiopeixoto-seequent Aug 10, 2026
346325d
feat(objects): require explicit units for downhole distance and inter…
sergiopeixoto-seequent Aug 10, 2026
e4b6ae7
feat(objects): moved method to parent class
sergiopeixoto-seequent Aug 10, 2026
ffc4d3d
feat(objects): update interval table to use DepthIntervalsTable
sergiopeixoto-seequent Aug 10, 2026
97c220d
feat(objects): enforce unique collection names and handle legacy dupl…
sergiopeixoto-seequent Aug 10, 2026
fca9d14
feat(objects): enhance prefetch_collections method with proper names …
sergiopeixoto-seequent Aug 10, 2026
c82166d
feat(objects): enhance hole chunk validation with valid indices and r…
sergiopeixoto-seequent Aug 10, 2026
d96d54b
feat(objects): refactor DownholeCollection classes to share common be…
sergiopeixoto-seequent Aug 10, 2026
21d2e6b
feat(objects): moves import to module-level
sergiopeixoto-seequent Aug 11, 2026
dbbd998
feat(objects): lower the default max concurrency for pre-fetching
sergiopeixoto-seequent Aug 11, 2026
4ce8684
feat(objects): enforces stricted unit type checking
sergiopeixoto-seequent Aug 11, 2026
1a7a2d6
fix(objects): treat downhole hole_index as an opaque lookup key, hole…
sergiopeixoto-seequent Aug 11, 2026
a02ac93
feat(objects): enhance DownholeCollectionTable methods with detailed …
sergiopeixoto-seequent Aug 11, 2026
4145125
docs(objects): clarify unit descriptions for collection coordinates a…
sergiopeixoto-seequent Aug 11, 2026
1b48a4e
feat(objects): refine hole index handling and validation in DownholeC…
sergiopeixoto-seequent Aug 14, 2026
ea86f6e
docs(objects): update documentation for hole_chunks_from_ids to clari…
sergiopeixoto-seequent Aug 28, 2026
c56543e
Merge branch 'SeequentEvo:main' into AD2052323-dhc-intervals
sergiopeixoto-seequent Aug 28, 2026
032b3e1
feat(downhole): enhance hole index handling and validation, update do…
sergiopeixoto-seequent Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,15 @@
" \"count\": [4],\n",
" }\n",
" ).astype({\"hole_index\": np.int32, \"offset\": np.uint64, \"count\": np.uint64}),\n",
" distance_table=pd.DataFrame(\n",
" table=pd.DataFrame(\n",
" {\n",
" \"distance\": [0.0, 10.0, 20.0, 30.0],\n",
" \"attr_str\": [\"a\", \"b\", \"a\", \"c\"],\n",
" \"attr_dt\": [date(2000, 1, 1), date(2000, 1, 2), date(2000, 1, 3), date(2000, 1, 4)],\n",
" \"attr_num\": [1.1, 2.2, 3.3, 4.4],\n",
" }\n",
" ),\n",
" unit=\"m\",\n",
")\n",
"\n",
"dhc_data = DownholeCollectionData(\n",
Expand Down
36 changes: 36 additions & 0 deletions packages/evo-objects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ You can also get a list of all objects. Internally, this recursively calls the `

Check out the other methods on the `ObjectAPIClient` for more details on how to upload and download objects, or get object versions.

### Typed downhole collections

`DownholeCollection` provides a DataFrame-based API for creating and reading downhole objects. A collection can contain
distance tables, interval tables, or both. `hole_index` is an integer key in the object's `hole_id` lookup table, not a
row position or a pandas categorical code. The creation helper uses dense zero-based keys in sorted hole-ID order unless
an explicit `hole_indices` mapping is supplied. Persisted objects may use arbitrary integer keys. Collection holes may
contain a subset or repeated chunks.

```python
import pandas as pd

from evo.objects.typed import DownholeCollection, DownholeCollectionData
from evo.objects.typed.downhole_collection import IntervalCollection
from evo.objects.utils.downhole import hole_chunks_from_ids

intervals = pd.DataFrame({"from": [0.0], "to": [1.5], "lithology": ["sandstone"]})
collections = [
IntervalCollection(
name="geology",
holes=hole_chunks_from_ids(pd.Series(["DH-01"])),
table=intervals,
unit="m", # Collection coordinate units are set explicitly here.
)
]

# Build DownholeCollectionData with matching path, location-hole chunks, and collar properties,
# then create it with: await DownholeCollection.create(context, data)
```

Use `await dhc.location.to_dataframe()` and `await dhc.location.path_to_dataframe()` to read collars and paths.
Distance and interval tables provide `to_dataframe()` and `to_dataframe_by_hole()`. Before reading a large object, call
`await dhc.prefetch_collections("geology")` to warm only the requested collection data (and location data by default).
Attribute descriptions for ordinary attributes round-trip through `DataFrame.attrs["attribute_descriptions"]`.
Collection coordinate units come only from `DistanceCollection.unit` or `IntervalCollection.unit`; unit metadata on the
distance or `from` column is ignored.

## Contributing

For instructions on contributing to the development of this library, please refer to the [evo-python-sdk documentation](https://github.com/seequentevo/evo-python-sdk).
Expand Down
2 changes: 1 addition & 1 deletion packages/evo-objects/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "evo-objects"
description = "Python SDK for using the Seequent Evo Geoscience Object API"
version = "0.6.1"
version = "0.6.2"
requires-python = ">=3.10"
license-files = ["LICENSE.md"]
dynamic = ["readme"]
Expand Down
13 changes: 12 additions & 1 deletion packages/evo-objects/src/evo/objects/typed/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,24 @@
from ._grid import BlockModelData, BlockModelGeometry
from .attributes import (
Attribute,
AttributeDescription,
Attributes,
BlockModelAttribute,
BlockModelAttributes,
BlockModelPendingAttribute,
PendingAttribute,
Unit,
)
from .base import BaseObject, object_from_path, object_from_reference, object_from_uuid
from .block_model_ref import (
BlockModel,
)
from .downhole_collection import DownholeCollection, DownholeCollectionData
from .downhole_collection import (
DistanceCollection,
DownholeCollection,
DownholeCollectionData,
IntervalCollection,
)
from .downhole_intervals import DownholeIntervals, DownholeIntervalsData
from .pointset import (
Locations,
Expand Down Expand Up @@ -70,6 +77,7 @@

__all__ = [
"Attribute",
"AttributeDescription",
"Attributes",
"BaseObject",
"BaseSpatialObject",
Expand All @@ -82,6 +90,7 @@
"BoundingBox",
"CoordinateReferenceSystem",
"CubicStructure",
"DistanceCollection",
"DownholeCollection",
"DownholeCollectionData",
"DownholeIntervals",
Expand All @@ -92,6 +101,7 @@
"ExponentialStructure",
"GaussianStructure",
"GeneralisedCauchyStructure",
"IntervalCollection",
"LinearStructure",
"Locations",
"MaskedCells",
Expand All @@ -110,6 +120,7 @@
"SpheroidalStructure",
"Tensor3DGrid",
"Tensor3DGridData",
"Unit",
"Variogram",
"VariogramCurveData",
"VariogramData",
Expand Down
21 changes: 18 additions & 3 deletions packages/evo-objects/src/evo/objects/typed/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,15 +450,30 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
if get_origin(base) is SchemaList:
args = get_args(base)
if args:
cls._item_type = args[0]
item_type = args[0]
if isinstance(item_type, type):
cls._item_type = item_type
elif "_resolve_item_type" not in cls.__dict__ or "_data_to_schema" not in cls.__dict__:
raise TypeError(
"SchemaList with a non-class item type must override _resolve_item_type() and _data_to_schema()"
)
Comment thread
daniel-kinney-seequent marked this conversation as resolved.
break

def __getitem__(self, index: int) -> _M:
return self._item_type(self._context, self._document[index])
document = self._document[index]
return self._resolve_item_type(document)(self._context, document)

def __iter__(self):
for item in self._document:
yield self._item_type(self._context, item)
yield self._resolve_item_type(item)(self._context, item)

@classmethod
def _resolve_item_type(cls, document: dict[str, Any]) -> type[_M]:
"""Resolve the schema model used for a document item.

Subclasses with heterogeneous document items can override this hook.
"""
return cls._item_type

def __len__(self) -> int:
return len(self._document)
Expand Down
72 changes: 72 additions & 0 deletions packages/evo-objects/src/evo/objects/typed/_prefetch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright © 2026 Bentley Systems, Incorporated
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Helpers for warming typed-object data in the local cache."""

from __future__ import annotations

import asyncio
from collections.abc import Sequence
from typing import Any

from evo.common import IFeedback
from evo.common.utils import NoFeedback
from evo.objects import DownloadedObject
from evo.objects.io import _CACHE_SCOPE


def collect_data_ids(documents: Any) -> list[str]:
"""Collect de-duplicated ``data`` references from document values."""
ids: list[str] = []

def visit(value: Any) -> None:
if isinstance(value, dict):
for key, nested in value.items():
if key == "data" and isinstance(nested, str) and nested not in ids:
ids.append(nested)
else:
visit(nested)
elif isinstance(value, (list, tuple)):
for nested in value:
visit(nested)

visit(documents)
return ids


async def prefetch_object_data(
obj: DownloadedObject,
*,
data_ids: Sequence[str] | None = None,
max_concurrent: int = 8,
fb: IFeedback = NoFeedback,
) -> None:
"""Warm cache entries referenced by an object, downloading each ID at most once."""
if max_concurrent < 1:
raise ValueError("max_concurrent must be at least 1")
cache = obj.get_cache()
if cache is None:
raise ValueError("prefetch requires an IContext with a cache")
identifiers = list(dict.fromkeys(data_ids if data_ids is not None else collect_data_ids(obj.as_dict())))
if not identifiers:
return
cache_location = cache.get_location(environment=obj.get_environment(), scope=_CACHE_SCOPE)
identifiers = [identifier for identifier in identifiers if not (cache_location / identifier).exists()]
if not identifiers:
return
contexts = list(obj.prepare_data_download(identifiers))
semaphore = asyncio.Semaphore(max_concurrent)

async def download(context: Any) -> None:
async with semaphore:
await context.download_to_cache(cache, obj.get_connector().transport, fb=fb)

await asyncio.gather(*(download(context) for context in contexts))
58 changes: 50 additions & 8 deletions packages/evo-objects/src/evo/objects/typed/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import typing
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any, Protocol, runtime_checkable
from uuid import UUID

import pandas as pd
Expand All @@ -41,18 +41,27 @@

__all__ = [
"Attribute",
"AttributeDescription",
"Attributes",
"BlockModelAttribute",
"BlockModelAttributes",
"BlockModelPendingAttribute",
"PendingAttribute",
"Unit",
]


class UnSupportedDataTypeError(Exception):
"""An unsupported data type was encountered while processing data."""


@runtime_checkable
class Unit(Protocol):
"""A schema unit enum represented by its string value."""

value: str


def _infer_attribute_type_from_series(series: pd.Series) -> str:
"""Infer the attribute type from a Pandas Series.

Expand Down Expand Up @@ -89,13 +98,20 @@ def _infer_attribute_type_from_series(series: pd.Series) -> str:
class AttributeDescription:
discipline: str = ""
type: str = ""
unit: str | None = None
unit: str | Unit | None = None
scale: str | None = None
extensions: dict[str, typing.Any] | None = None
tags: dict[str, str] | None = None

def to_schema(self):
result = {
def __post_init__(self) -> None:
if self.unit is None or isinstance(self.unit, str):
return
if not isinstance(self.unit, Unit) or not isinstance(self.unit.value, str):
raise TypeError("unit must be a str, a Unit with a string value, or None")
self.unit = self.unit.value

def to_schema(self) -> dict[str, Any]:
result: dict[str, Any] = {
"discipline": self.discipline,
"type": self.type,
}
Expand All @@ -117,6 +133,7 @@ class Attribute(SchemaModel):
_attribute_type: Annotated[str, SchemaLocation("attribute_type")]
_key: Annotated[str | None, SchemaLocation("key")]
_data: Annotated[str, SchemaLocation("values.data")]
_attribute_description: Annotated[dict[str, Any] | None, SchemaLocation("attribute_description")]

@property
def key(self) -> str:
Expand All @@ -132,6 +149,12 @@ def attribute_type(self) -> str:
"""The type of this attribute."""
return self._attribute_type

@property
def attribute_description(self) -> AttributeDescription | None:
"""Optional descriptive metadata associated with this attribute."""
raw = self._attribute_description
return AttributeDescription(**raw) if raw else None

@property
def exists(self) -> bool:
"""Whether this attribute exists on the object.
Expand Down Expand Up @@ -241,7 +264,7 @@ class Attributes(SchemaList[Attribute]):
attribute descriptions are attached to the DataFrame's `attrs` attribute.

>>> df.attrs
{'attribute_description': {<column names>: <AttributeDescription>}, ...}
{'attribute_descriptions': {<column names>: <AttributeDescription>}, ...}
"""

_schema_path: str | None = None
Expand Down Expand Up @@ -320,8 +343,8 @@ async def _upload_attributes_to_list(
if attr_desc is not None:
if not isinstance(attr_desc, AttributeDescription):
raise TypeError("attribute description must be a AttributeDescription.")
if attr_desc.unit is not None:
attr_doc["attribute_description"] = attr_desc.to_schema()
if description := attr_desc.to_schema():
attr_doc["attribute_description"] = description

attributes_list.append(attr_doc)

Expand All @@ -337,7 +360,15 @@ async def to_dataframe(self, *keys: str, fb: IFeedback = NoFeedback) -> pd.DataF
"""
attributes = [self[key] for key in keys] if keys else list(self)
parts = [await attribute.to_dataframe(fb=fb_part) for attribute, fb_part in iter_with_fb(attributes, fb)]
return pd.concat(parts, axis=1) if len(parts) > 0 else pd.DataFrame()
result = pd.concat(parts, axis=1) if len(parts) > 0 else pd.DataFrame()
descriptions = {
attribute.name: description
for attribute in attributes
if isinstance(attribute, Attribute) and (description := attribute.attribute_description) is not None
}
if descriptions:
result.attrs["attribute_descriptions"] = descriptions
return result

async def append_attribute(self, df: pd.DataFrame, fb: IFeedback = NoFeedback):
"""Add a new attribute to the object.
Expand Down Expand Up @@ -601,3 +632,14 @@ async def to_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame:
if self._context.is_data_modified(self._data):
raise DataLoaderError("Data was modified since the object was downloaded")
return await self._obj.download_category_dataframe(self.as_dict(), fb=fb)

async def to_indexed_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame:
"""Load the persisted category lookup as ``[key, value]`` rows.

Pandas categorical codes are dense positional values and must not be used
as schema lookup keys. This method is intended for joins involving an
index column such as ``hole_index``.
"""
if self._context.is_data_modified(self._data):
raise DataLoaderError("Data was modified since the object was downloaded")
return await self._obj.download_dataframe(self.as_dict()["table"], fb=fb)
Loading