evo-objects 0.3.3__tar.gz → 0.4.0__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 (45) hide show
  1. {evo_objects-0.3.3 → evo_objects-0.4.0}/.gitignore +10 -1
  2. {evo_objects-0.3.3 → evo_objects-0.4.0}/PKG-INFO +3 -1
  3. {evo_objects-0.3.3 → evo_objects-0.4.0}/pyproject.toml +3 -2
  4. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/client/api_client.py +16 -1
  5. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/client/object_client.py +62 -27
  6. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/parquet/__init__.py +1 -1
  7. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/parquet/loader.py +1 -2
  8. evo_objects-0.4.0/src/evo/objects/typed/__init__.py +140 -0
  9. evo_objects-0.4.0/src/evo/objects/typed/_data.py +146 -0
  10. evo_objects-0.4.0/src/evo/objects/typed/_grid.py +259 -0
  11. evo_objects-0.4.0/src/evo/objects/typed/_model.py +438 -0
  12. evo_objects-0.4.0/src/evo/objects/typed/_utils.py +188 -0
  13. evo_objects-0.4.0/src/evo/objects/typed/attributes.py +530 -0
  14. evo_objects-0.4.0/src/evo/objects/typed/base.py +500 -0
  15. evo_objects-0.4.0/src/evo/objects/typed/block_model_ref.py +386 -0
  16. evo_objects-0.4.0/src/evo/objects/typed/exceptions.py +22 -0
  17. evo_objects-0.4.0/src/evo/objects/typed/pointset.py +141 -0
  18. evo_objects-0.4.0/src/evo/objects/typed/regular_grid.py +84 -0
  19. evo_objects-0.4.0/src/evo/objects/typed/regular_masked_grid.py +191 -0
  20. evo_objects-0.4.0/src/evo/objects/typed/spatial.py +76 -0
  21. evo_objects-0.4.0/src/evo/objects/typed/tensor_grid.py +162 -0
  22. evo_objects-0.4.0/src/evo/objects/typed/types.py +388 -0
  23. evo_objects-0.4.0/src/evo/objects/typed/variogram.py +663 -0
  24. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/utils/__init__.py +6 -0
  25. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/utils/data.py +140 -18
  26. evo_objects-0.4.0/src/evo/objects/utils/table_formats.py +221 -0
  27. {evo_objects-0.3.3/src/evo/objects/parquet → evo_objects-0.4.0/src/evo/objects/utils}/types.py +1 -0
  28. evo_objects-0.3.3/src/evo/objects/utils/table_formats.py +0 -158
  29. {evo_objects-0.3.3 → evo_objects-0.4.0}/LICENSE.md +0 -0
  30. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/__init__.py +0 -0
  31. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/_model_config.py +0 -0
  32. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/client/__init__.py +0 -0
  33. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/client/parse.py +0 -0
  34. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/data.py +0 -0
  35. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/__init__.py +0 -0
  36. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/api/__init__.py +0 -0
  37. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/api/data_api.py +0 -0
  38. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
  39. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/api/objects_api.py +0 -0
  40. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/api/stages_api.py +0 -0
  41. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/endpoints/models.py +0 -0
  42. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/exceptions.py +0 -0
  43. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/io.py +0 -0
  44. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/py.typed +0 -0
  45. {evo_objects-0.3.3 → evo_objects-0.4.0}/src/evo/objects/utils/tables.py +0 -0
@@ -14,7 +14,7 @@ build/
14
14
  # Reporting
15
15
  .coverage
16
16
 
17
- # Environmnents
17
+ # Environments
18
18
  venv/
19
19
  venv*/
20
20
  .venv/
@@ -31,3 +31,12 @@ samples/*/download*/data/*
31
31
 
32
32
  # macOS invisible files
33
33
  .DS_Store
34
+
35
+ # MkDocs
36
+ mkdocs/site/*
37
+ !mkdocs/site/packages/
38
+ !mkdocs/site/packages/**
39
+ !mkdocs/site/
40
+
41
+ # Other
42
+ drilling_campaign.xlsx
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: evo-objects
3
- Version: 0.3.3
3
+ Version: 0.4.0
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
@@ -13,6 +13,8 @@ Requires-Dist: evo-sdk-common[jmespath]>=0.5.8
13
13
  Requires-Dist: pydantic<3,>=2
14
14
  Provides-Extra: aiohttp
15
15
  Requires-Dist: evo-sdk-common[aiohttp]; extra == 'aiohttp'
16
+ Provides-Extra: blockmodels
17
+ Requires-Dist: evo-blockmodels[utils]>=0.2.0; extra == 'blockmodels'
16
18
  Provides-Extra: notebooks
17
19
  Requires-Dist: evo-sdk-common[notebooks]; extra == 'notebooks'
18
20
  Provides-Extra: utils
@@ -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.3.3"
4
+ version = "0.4.0"
5
5
  requires-python = ">=3.10"
6
6
  license-files = ["LICENSE.md"]
7
7
  dynamic = ["readme"]
@@ -24,11 +24,12 @@ Documentation = "https://developer.seequent.com/"
24
24
  aiohttp = ["evo-sdk-common[aiohttp]"]
25
25
  notebooks = ["evo-sdk-common[notebooks]"]
26
26
  utils = ["pyarrow", "pyarrow-stubs", "pandas", "numpy"]
27
+ blockmodels = ["evo-blockmodels[utils]>=0.2.0"]
27
28
 
28
29
  [dependency-groups]
29
30
  # Dev dependencies. The version is left unspecified so the latest is installed.
30
31
  test = [
31
- "evo-objects[aiohttp,utils]",
32
+ "evo-objects[aiohttp,utils,blockmodels]",
32
33
  "pandas",
33
34
  "parameterized==0.9.0",
34
35
  "pytest",
@@ -15,7 +15,7 @@ from collections.abc import AsyncIterator, Sequence
15
15
  from uuid import UUID
16
16
 
17
17
  from evo import logging
18
- from evo.common import APIConnector, BaseAPIClient, HealthCheckType, ICache, Page, ServiceHealth
18
+ from evo.common import APIConnector, BaseAPIClient, HealthCheckType, ICache, IContext, Page, ServiceHealth
19
19
  from evo.common.data import EmptyResponse, Environment, OrderByOperatorEnum
20
20
  from evo.common.utils import get_service_health, parse_order_by
21
21
 
@@ -52,6 +52,21 @@ class ObjectAPIClient(BaseAPIClient):
52
52
  self._metadata_api = MetadataApi(connector=connector)
53
53
  self._cache = cache
54
54
 
55
+ @classmethod
56
+ def from_context(cls, context: IContext) -> ObjectAPIClient:
57
+ """Create a ObjectAPIClient from the given context.
58
+
59
+ The context must have a hub_url, org_id, and workspace_id set.
60
+
61
+ :param context: The context to create the client from.
62
+ :return: A ObjectAPIClient instance.
63
+ """
64
+ return cls(
65
+ environment=context.get_environment(),
66
+ connector=context.get_connector(),
67
+ cache=context.get_cache(),
68
+ )
69
+
55
70
  async def get_service_health(self, check_type: HealthCheckType = HealthCheckType.FULL) -> ServiceHealth:
56
71
  """Get the health of the geoscience object service.
57
72
 
@@ -11,6 +11,7 @@
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import asyncio
14
15
  import contextlib
15
16
  from collections.abc import AsyncGenerator, Iterator, Sequence
16
17
  from typing import Any, TypeVar
@@ -19,9 +20,9 @@ from uuid import UUID
19
20
  from pydantic import ConfigDict, TypeAdapter
20
21
 
21
22
  from evo import jmespath, logging
22
- from evo.common import APIConnector, ICache, IFeedback
23
+ from evo.common import APIConnector, Environment, ICache, IContext, IFeedback
23
24
  from evo.common.io.exceptions import DataNotFoundError
24
- from evo.common.utils import NoFeedback, PartialFeedback
25
+ from evo.common.utils import NoFeedback, split_feedback
25
26
 
26
27
  from ..data import ObjectMetadata, ObjectReference, ObjectSchema
27
28
  from ..endpoints import ObjectsApi, models
@@ -73,29 +74,12 @@ logger = logging.getLogger("object.client")
73
74
  _T = TypeVar("_T")
74
75
 
75
76
 
76
- def _split_feedback(left: int, right: int) -> float:
77
- """Helper to split feedback range into two parts based on left and right sizes.
77
+ class DownloadedObject(IContext):
78
+ """A downloaded geoscience object.
78
79
 
79
- :param left: Number of parts for the left side of the split.
80
- :param right: Number of parts for the right side of the split.
81
-
82
- :return: Proportion of feedback to allocate to the left side (between 0.0 and 1.0).
83
- The right side will be the remainder (1.0 - proportion).
84
-
85
- :raises ValueError: If left or right is negative.
80
+ This class also implements the IContext interface, allowing it to be used
81
+ directly as a context for further API operations.
86
82
  """
87
- if left < 0 or right < 0:
88
- raise ValueError("Left and right sizes must be non-negative")
89
- elif left >= 0 and right == 0:
90
- return 1.0 # Left gets all feedback if right is zero
91
- elif right > 0 and left == 0:
92
- return 0.0 # Right gets all feedback if left is zero
93
- else:
94
- return left / (left + right) # Proportion of feedback for left
95
-
96
-
97
- class DownloadedObject:
98
- """A downloaded geoscience object."""
99
83
 
100
84
  def __init__(
101
85
  self,
@@ -172,6 +156,25 @@ class DownloadedObject:
172
156
  cache=cache,
173
157
  )
174
158
 
159
+ @staticmethod
160
+ async def from_context(
161
+ context: IContext,
162
+ reference: ObjectReference | str,
163
+ ) -> DownloadedObject:
164
+ """Download a geoscience object from the service using a context.
165
+
166
+ :param context: The context providing the connector and cache.
167
+ :param reference: The reference to the object to download, or a URL as a string that can be parsed into
168
+ a reference.
169
+
170
+ :raises ValueError: If the reference is invalid, or if the connector base URL does not match the reference hub URL.
171
+ """
172
+ return await DownloadedObject.from_reference(
173
+ connector=context.get_connector(),
174
+ reference=reference,
175
+ cache=context.get_cache(),
176
+ )
177
+
175
178
  @property
176
179
  def schema(self) -> ObjectSchema:
177
180
  """The schema of the object."""
@@ -182,6 +185,36 @@ class DownloadedObject:
182
185
  """The metadata of the object."""
183
186
  return self._metadata
184
187
 
188
+ # IContext interface implementation
189
+
190
+ def get_environment(self) -> Environment:
191
+ """Gets the Environment associated with this object.
192
+
193
+ :return: The Environment.
194
+ """
195
+ return self._metadata.environment
196
+
197
+ def get_org_id(self) -> UUID:
198
+ """Gets the organization ID associated with this object.
199
+
200
+ :return: The organization ID.
201
+ """
202
+ return self._metadata.environment.org_id
203
+
204
+ def get_connector(self) -> APIConnector:
205
+ """Gets the APIConnector associated with this object.
206
+
207
+ :return: The APIConnector.
208
+ """
209
+ return self._connector
210
+
211
+ def get_cache(self) -> ICache | None:
212
+ """Gets the ICache associated with this object, if any.
213
+
214
+ :return: The ICache, or None if no cache is associated.
215
+ """
216
+ return self._cache
217
+
185
218
  def as_dict(self) -> dict:
186
219
  """Get this object as a dictionary."""
187
220
  return self._object.model_dump(mode="python", by_alias=True)
@@ -384,15 +417,17 @@ class DownloadedObject:
384
417
  category_info["values"]["length"] * category_info["values"]["width"]
385
418
  ) # Total number of cells in values
386
419
  t_size = category_info["table"]["length"] * 2 # Lookup tables always have 2 columns
387
- split = _split_feedback(v_size, t_size)
420
+ values_fb, table_fb = split_feedback(fb, [v_size, t_size])
388
421
 
389
- values_table = await self.download_table(
422
+ # Download both tables concurrently
423
+ values_table_coro = self.download_table(
390
424
  category_info["values"],
391
425
  nan_values=nan_values,
392
426
  column_names=column_names,
393
- fb=PartialFeedback(fb, start=0, end=split),
427
+ fb=values_fb,
394
428
  )
395
- lookup_table = await self.download_table(category_info["table"], fb=PartialFeedback(fb, start=split, end=1))
429
+ lookup_table_coro = self.download_table(category_info["table"], fb=table_fb)
430
+ values_table, lookup_table = await asyncio.gather(values_table_coro, lookup_table_coro)
396
431
 
397
432
  arrays = []
398
433
  for array in values_table.columns:
@@ -14,8 +14,8 @@ try:
14
14
  except ImportError:
15
15
  raise ImportError("The 'pyarrow' package is required to use ParquetLoader") from None
16
16
 
17
+ from ..utils.types import ArrayTableInfo, AttributeInfo, CategoryInfo, LookupTableInfo, TableInfo
17
18
  from .loader import ParquetDownloader, ParquetLoader
18
- from .types import ArrayTableInfo, AttributeInfo, CategoryInfo, LookupTableInfo, TableInfo
19
19
 
20
20
  __all__ = [
21
21
  "ArrayTableInfo",
@@ -27,8 +27,7 @@ from evo.common.io import BytesDestination, ChunkedIOManager, Download, HTTPSour
27
27
  from evo.common.utils import NoFeedback
28
28
 
29
29
  from ..exceptions import SchemaValidationError, TableFormatError
30
- from ..utils import ArrowTableFormat, KnownTableFormat
31
- from .types import TableInfo
30
+ from ..utils import ArrowTableFormat, KnownTableFormat, TableInfo
32
31
 
33
32
  try:
34
33
  import pandas as pd
@@ -0,0 +1,140 @@
1
+ # Copyright © 2025 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 ._grid import BlockModelData, BlockModelGeometry
13
+ from .attributes import (
14
+ Attribute,
15
+ Attributes,
16
+ BlockModelAttribute,
17
+ BlockModelAttributes,
18
+ BlockModelPendingAttribute,
19
+ PendingAttribute,
20
+ )
21
+ from .base import BaseObject, object_from_path, object_from_reference, object_from_uuid
22
+ from .block_model_ref import (
23
+ BlockModel,
24
+ )
25
+ from .pointset import (
26
+ Locations,
27
+ PointSet,
28
+ PointSetData,
29
+ )
30
+ from .regular_grid import (
31
+ Regular3DGrid,
32
+ Regular3DGridData,
33
+ )
34
+ from .regular_masked_grid import (
35
+ MaskedCells,
36
+ RegularMasked3DGrid,
37
+ RegularMasked3DGridData,
38
+ )
39
+ from .spatial import BaseSpatialObject
40
+ from .tensor_grid import (
41
+ Tensor3DGrid,
42
+ Tensor3DGridData,
43
+ )
44
+ from .types import (
45
+ BoundingBox,
46
+ CoordinateReferenceSystem,
47
+ Ellipsoid,
48
+ EllipsoidRanges,
49
+ EpsgCode,
50
+ Point3,
51
+ Rotation,
52
+ Size3d,
53
+ Size3i,
54
+ )
55
+ from .variogram import (
56
+ CubicStructure,
57
+ ExponentialStructure,
58
+ GaussianStructure,
59
+ GeneralisedCauchyStructure,
60
+ LinearStructure,
61
+ SphericalStructure,
62
+ SpheroidalStructure,
63
+ Variogram,
64
+ VariogramCurveData,
65
+ VariogramData,
66
+ VariogramStructure,
67
+ )
68
+
69
+ __all__ = [
70
+ "Attribute",
71
+ "Attributes",
72
+ "BaseObject",
73
+ "BaseSpatialObject",
74
+ "BlockModel",
75
+ "BlockModelAttribute",
76
+ "BlockModelAttributes",
77
+ "BlockModelData",
78
+ "BlockModelGeometry",
79
+ "BlockModelPendingAttribute",
80
+ "BoundingBox",
81
+ "CoordinateReferenceSystem",
82
+ "CubicStructure",
83
+ "Ellipsoid",
84
+ "EllipsoidRanges",
85
+ "EpsgCode",
86
+ "ExponentialStructure",
87
+ "GaussianStructure",
88
+ "GeneralisedCauchyStructure",
89
+ "LinearStructure",
90
+ "Locations",
91
+ "MaskedCells",
92
+ "PendingAttribute",
93
+ "Point3",
94
+ "PointSet",
95
+ "PointSetData",
96
+ "Regular3DGrid",
97
+ "Regular3DGridData",
98
+ "RegularMasked3DGrid",
99
+ "RegularMasked3DGridData",
100
+ "Rotation",
101
+ "Size3d",
102
+ "Size3i",
103
+ "SphericalStructure",
104
+ "SpheroidalStructure",
105
+ "Tensor3DGrid",
106
+ "Tensor3DGridData",
107
+ "Variogram",
108
+ "VariogramCurveData",
109
+ "VariogramData",
110
+ "VariogramStructure",
111
+ "object_from_path",
112
+ "object_from_reference",
113
+ "object_from_uuid",
114
+ ]
115
+
116
+ # Conditionally export report types when evo-blockmodels is installed
117
+ try:
118
+ from evo.blockmodels.typed import ( # noqa: F401
119
+ Aggregation,
120
+ MassUnits,
121
+ RegularBlockModelData,
122
+ Report,
123
+ ReportCategorySpec,
124
+ ReportColumnSpec,
125
+ ReportResult,
126
+ ReportSpecificationData,
127
+ )
128
+
129
+ __all__ += [
130
+ "Aggregation",
131
+ "MassUnits",
132
+ "RegularBlockModelData",
133
+ "Report",
134
+ "ReportCategorySpec",
135
+ "ReportColumnSpec",
136
+ "ReportResult",
137
+ "ReportSpecificationData",
138
+ ]
139
+ except ImportError:
140
+ pass
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any, ClassVar
4
+
5
+ import pandas as pd
6
+
7
+ from evo.common import IFeedback
8
+ from evo.common.interfaces import IContext
9
+ from evo.common.utils import NoFeedback
10
+ from evo.objects.typed.attributes import Attributes
11
+ from evo.objects.utils.table_formats import KnownTableFormat
12
+
13
+ from ._model import SchemaBuilder, SchemaLocation, SchemaModel
14
+ from ._utils import get_data_client
15
+ from .exceptions import DataLoaderError, ObjectValidationError
16
+
17
+
18
+ class DataTable(SchemaModel):
19
+ length: Annotated[int, SchemaLocation("length")]
20
+ _data: Annotated[str, SchemaLocation("data")]
21
+
22
+ table_format: ClassVar[KnownTableFormat | None] = None
23
+ data_columns: ClassVar[list[str]] = []
24
+
25
+ async def to_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame:
26
+ """Load a DataFrame containing values for this table.
27
+
28
+ :param fb: Optional feedback object to report download progress.
29
+ :return: The loaded DataFrame with values for this table.
30
+ """
31
+ if self._context.is_data_modified(self._data):
32
+ raise DataLoaderError("Data was modified since the object was downloaded")
33
+ return await self._obj.download_dataframe(self.as_dict(), fb=fb, column_names=self.data_columns)
34
+
35
+ async def from_dataframe(self, df: pd.DataFrame, fb: IFeedback = NoFeedback) -> None:
36
+ """Update the values of this table.
37
+
38
+ :param df: DataFrame containing the new values for this table.
39
+ :param fb: Optional feedback object to report upload progress.
40
+ """
41
+ self._document.update(await self._data_to_schema(df, self._context, fb=fb))
42
+
43
+ # Mark the context as modified so loading data is not allowed
44
+ self._context.mark_modified(self._data)
45
+
46
+ @classmethod
47
+ async def _data_to_schema(cls, data: Any, context: IContext, fb: IFeedback = NoFeedback) -> Any:
48
+ """Upload a DataFrame and return the schema dictionary for the DataTable."""
49
+ if not isinstance(data, pd.DataFrame):
50
+ raise ObjectValidationError(f"Input data must be a pandas DataFrame, but got {type(data)}")
51
+ if list(data.columns) != cls.data_columns:
52
+ raise ObjectValidationError(
53
+ f"Input DataFrame must have columns {cls.data_columns}, but got {list(data.columns)}"
54
+ )
55
+
56
+ data_client = get_data_client(context)
57
+ return await data_client.upload_dataframe(data, table_format=cls.table_format, fb=fb)
58
+
59
+
60
+ class DataTableAndAttributes(SchemaModel):
61
+ """A dataset representing a table of data along with associated attributes.
62
+
63
+ Subclasses should redefine the _table property to provide additional details about the data table like:
64
+ 1. the location of it within the schema using SchemaLocation
65
+ 2. the data columns that are expected in the table, which is done by creating a subclass of DataTable,
66
+ 3. the table format used for storing the data, which can also be done by creating a subclass of DataTable.
67
+
68
+ e.g.,
69
+ class LocationTable(DataTable):
70
+ table_format: ClassVar[KnownTableFormat] = FLOAT_ARRAY_3
71
+ data_columns: ClassVar[list[str]] = ["x", "y", "z"]
72
+
73
+
74
+ class Locations(DataTableAndAttributes):
75
+ _table: Annotated[LocationTable, SchemaLocation("coordinates")]
76
+
77
+ """
78
+
79
+ attributes: Annotated[Attributes, SchemaLocation("attributes")]
80
+ _table: DataTable
81
+
82
+ @property
83
+ def length(self) -> int:
84
+ """The expected number of rows in the table and attributes."""
85
+ return self._table.length
86
+
87
+ async def to_dataframe(self, *keys: str, fb: IFeedback = NoFeedback) -> pd.DataFrame:
88
+ """Load a DataFrame containing the values and attributes.
89
+
90
+ :param keys: Optional attribute keys to include. If not specified, all attributes are included.
91
+ :param fb: Optional feedback object to report download progress.
92
+ :return: DataFrame with data columns (e.g., X, Y, Z) and additional columns for attributes.
93
+ """
94
+ table_df = await self._table.to_dataframe(fb=fb)
95
+ if self.attributes is not None and len(self.attributes) > 0:
96
+ attr_df = await self.attributes.to_dataframe(*keys, fb=fb)
97
+ combined_df = pd.concat([table_df, attr_df], axis=1)
98
+ return combined_df
99
+ else:
100
+ return table_df
101
+
102
+ async def from_dataframe(self, df: pd.DataFrame, fb: IFeedback = NoFeedback) -> None:
103
+ """Set the table data and attributes from a DataFrame.
104
+
105
+ :param df: DataFrame containing data columns (e.g., X, Y, Z) and additional columns for attributes.
106
+ :param fb: Optional feedback object to report upload progress.
107
+ """
108
+ table_df, attr_df = self._split_dataframe(df, self._table.data_columns)
109
+
110
+ await self._table.from_dataframe(table_df, fb=fb)
111
+ if attr_df is not None:
112
+ await self.attributes.set_attributes(attr_df, fb=fb)
113
+ else:
114
+ await self.attributes.clear()
115
+
116
+ def validate(self) -> None:
117
+ """Validate that all attributes have the correct length."""
118
+ self.attributes.validate_lengths(self.length)
119
+
120
+ @classmethod
121
+ def _split_dataframe(cls, data: pd.DataFrame, data_columns: list[str]) -> tuple[pd.DataFrame, pd.DataFrame | None]:
122
+ """Validate and split a DataFrame into table data and attribute data."""
123
+
124
+ missing = set(data_columns) - set(data.columns)
125
+ if missing:
126
+ raise ObjectValidationError(f"Input DataFrame must have {data_columns} columns. Missing: {missing}")
127
+
128
+ table_df = data[data_columns]
129
+ attr_cols = [col for col in data.columns if col not in data_columns]
130
+ attr_df = data[attr_cols] if attr_cols else None
131
+ return table_df, attr_df
132
+
133
+ @classmethod
134
+ async def _data_to_schema(cls, data: Any, context: IContext) -> Any:
135
+ if not isinstance(data, pd.DataFrame):
136
+ raise ObjectValidationError(f"Input data must be a pandas DataFrame, but got {type(data)}")
137
+
138
+ # Lookup the metadata of the _table sub-model, as sub-classes may redefine it
139
+ table_metadata = cls._sub_models["_table"]
140
+ table_type = table_metadata.model_type
141
+ table_df, attr_df = cls._split_dataframe(data, table_type.data_columns)
142
+
143
+ builder = SchemaBuilder(cls, context)
144
+ await builder.set_sub_model_value("_table", table_df)
145
+ await builder.set_sub_model_value("attributes", attr_df)
146
+ return builder.document