From 5d8bdfe8f2cb2787e22dba567769c41d200f6889 Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:03:24 +1200
Subject: [PATCH 01/21] reorder unit types so more commonly used are at top.
---
.../evo-blockmodels/src/evo/blockmodels/typed/units.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index bc55ef4f..447dc373 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -34,14 +34,14 @@
class UnitType(Enum):
"""Types of units supported by the Block Model Service."""
- LENGTH = "LENGTH"
+ MASS_PER_MASS = "MASS_PER_MASS"
+ MASS_PER_VOLUME = "MASS_PER_VOLUME"
MASS = "MASS"
+ LENGTH = "LENGTH"
VOLUME = "VOLUME"
+ VALUE_PER_MASS = "VALUE_PER_MASS"
VALUE = "VALUE"
- MASS_PER_VOLUME = "MASS_PER_VOLUME"
- MASS_PER_MASS = "MASS_PER_MASS"
VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
- VALUE_PER_MASS = "VALUE_PER_MASS"
@dataclass(frozen=True)
From 792a959de3db4e1a38cb20d0e8456c71c1943499 Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:06:03 +1200
Subject: [PATCH 02/21] use enum type for units
---
packages/evo-blockmodels/src/evo/blockmodels/typed/units.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index 447dc373..ad562206 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -64,7 +64,7 @@ class UnitInfo:
"""Conversion factor to the reference unit for this unit type."""
-class Units:
+class Units(Enum):
"""Common unit IDs for block model attributes.
These are the most commonly used unit IDs. For a complete list,
From bd118d5228a76d18a682e76a2989e16acab8e58e Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:15:49 +1200
Subject: [PATCH 03/21] remove module reference that does not exist
---
packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
index 1c2ce6a5..62808ec8 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
@@ -11,8 +11,6 @@
"""Typed access for block models with pandas DataFrame support."""
-from evo.common.typed import BoundingBox, Point3, Size3d, Size3i
-
from .base import BaseTypedBlockModel
from .regular_block_model import RegularBlockModel, RegularBlockModelData
from .report import (
From aed80d1bc2ee5c0125116b666b052ed4547e9ac5 Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:45:06 +1200
Subject: [PATCH 04/21] update order to match preferred user-shown order
---
.../src/evo/blockmodels/endpoints/models.py | 8 +--
.../src/evo/blockmodels/typed/units.py | 54 +++++++++----------
2 files changed, 31 insertions(+), 31 deletions(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
index 515c186a..605483ee 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
@@ -944,14 +944,14 @@ class SizeOptionsRegular(CustomBaseModel):
class UnitType(Enum):
- LENGTH = "LENGTH"
+ MASS_PER_MASS = "MASS_PER_MASS"
+ MASS_PER_VOLUME = "MASS_PER_VOLUME"
MASS = "MASS"
+ LENGTH = "LENGTH"
VOLUME = "VOLUME"
+ VALUE_PER_MASS = "VALUE_PER_MASS"
VALUE = "VALUE"
- MASS_PER_VOLUME = "MASS_PER_VOLUME"
- MASS_PER_MASS = "MASS_PER_MASS"
VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
- VALUE_PER_MASS = "VALUE_PER_MASS"
class UpdateMetadataValues(CustomBaseModel):
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index ad562206..197d0e1f 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -86,10 +86,25 @@ class Units(Enum):
await bm_ref.add_attribute(df, "metal_content", unit=Units.KILOS_PER_CUBIC_METRE)
"""
- # Length units
- METRES = "m"
- FEET = "ft"
- CENTIMETRES = "cm"
+ # Mass per mass (grade) units
+ PERCENT = "%[mass]"
+ PARTS_PER_MILLION = "ppm[mass]"
+ GRAMS_PER_TONNE = "g/t"
+ MILLIGRAMS_PER_GRAM = "mg/g"
+ MILLIGRAMS_PER_KILOGRAM = "mg/kg"
+ MICROGRAMS_PER_GRAM = "ug/g"
+ CARATS_PER_HUNDRED_TONNE = "0.01 ct/t"
+ PARTS_PER_BILLION = "ppb[mass]"
+ TROY_OUNCES_PER_SHORT_TON = "oz t/ton[US]"
+ CARATS_PER_TONNE = "ct/t"
+ MICROGRAMS_PER_KILOGRAM = "ug/kg"
+
+ # Mass per volume (density) units
+ KILOS_PER_CUBIC_METRE = "kg/m3"
+ GRAMS_PER_CUBIC_CENTIMETRE = "g/cm3"
+ POUNDS_PER_CUBIC_FOOT = "lbm/ft3"
+ TONNES_PER_CUBIC_METRE = "t/m3"
+ SHORT_TON_PER_CUBIC_FOOT = "ton[US]/ft3"
# Mass units
CARATS = "ct"
@@ -111,36 +126,21 @@ class Units(Enum):
MILLION_TROY_OUNCES = "1000000 ozm[troy]"
MILLION_POUNDS = "Mlbm"
- # Mass per mass (grade) units
- PERCENT = "%[mass]"
- PARTS_PER_MILLION = "ppm[mass]"
- GRAMS_PER_TONNE = "g/t"
- MILLIGRAMS_PER_GRAM = "mg/g"
- MILLIGRAMS_PER_KILOGRAM = "mg/kg"
- MICROGRAMS_PER_GRAM = "ug/g"
- CARATS_PER_HUNDRED_TONNE = "0.01 ct/t"
- PARTS_PER_BILLION = "ppb[mass]"
- TROY_OUNCES_PER_SHORT_TON = "oz t/ton[US]"
- CARATS_PER_TONNE = "ct/t"
- MICROGRAMS_PER_KILOGRAM = "ug/kg"
+ # Length units
+ METRES = "m"
+ FEET = "ft"
+ CENTIMETRES = "cm"
- # Mass per volume (density) units
- KILOS_PER_CUBIC_METRE = "kg/m3"
- GRAMS_PER_CUBIC_CENTIMETRE = "g/cm3"
- POUNDS_PER_CUBIC_FOOT = "lbm/ft3"
- TONNES_PER_CUBIC_METRE = "t/m3"
- SHORT_TON_PER_CUBIC_FOOT = "ton[US]/ft3"
+ # Volume units
+ CUBIC_CENTIMETRES = "cm3"
+ CUBIC_METRES = "m3"
+ CUBIC_FEET = "ft3"
# Value units
DOLLARS_PER_TONNE = "$/t"
DOLLARS_PER_SHORT_TON = "$/ton[US]"
DOLLARS = "$"
- # Volume units
- CUBIC_CENTIMETRES = "cm3"
- CUBIC_METRES = "m3"
- CUBIC_FEET = "ft3"
-
async def get_available_units(context: IContext) -> list[UnitInfo]:
"""Get the list of available units from the Block Model Service.
From a0fe52e73b6d45a782fa6785b258fb1bd5910d7e Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:48:10 +1200
Subject: [PATCH 05/21] add new units/types
---
.../src/evo/blockmodels/endpoints/models.py | 3 +++
.../src/evo/blockmodels/typed/units.py | 13 +++++++++++++
2 files changed, 16 insertions(+)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
index 605483ee..c910fac0 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
@@ -946,11 +946,14 @@ class SizeOptionsRegular(CustomBaseModel):
class UnitType(Enum):
MASS_PER_MASS = "MASS_PER_MASS"
MASS_PER_VOLUME = "MASS_PER_VOLUME"
+ DIMENSIONLESS = "DIMENSIONLESS"
MASS = "MASS"
LENGTH = "LENGTH"
VOLUME = "VOLUME"
VALUE_PER_MASS = "VALUE_PER_MASS"
VALUE = "VALUE"
+ CATEGORY = "CATEGORY"
+ ANGLE = "ANGLE"
VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index 197d0e1f..b7f2ddf5 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -36,11 +36,14 @@ class UnitType(Enum):
MASS_PER_MASS = "MASS_PER_MASS"
MASS_PER_VOLUME = "MASS_PER_VOLUME"
+ DIMENSIONLESS = "DIMENSIONLESS"
MASS = "MASS"
LENGTH = "LENGTH"
VOLUME = "VOLUME"
VALUE_PER_MASS = "VALUE_PER_MASS"
VALUE = "VALUE"
+ CATEGORY = "CATEGORY"
+ ANGLE = "ANGLE"
VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
@@ -106,6 +109,11 @@ class Units(Enum):
TONNES_PER_CUBIC_METRE = "t/m3"
SHORT_TON_PER_CUBIC_FOOT = "ton[US]/ft3"
+ # Dimensionless units
+ PERCENTAGE = "%"
+ COUNT = "count"
+ RATIO = "ratio"
+
# Mass units
CARATS = "ct"
GRAMS = "g"
@@ -141,6 +149,11 @@ class Units(Enum):
DOLLARS_PER_SHORT_TON = "$/ton[US]"
DOLLARS = "$"
+ # Angle units
+ DEGREES = "dega"
+ RADIANS = "rad"
+ REVOLUTIONS = "rev"
+
async def get_available_units(context: IContext) -> list[UnitInfo]:
"""Get the list of available units from the Block Model Service.
From ec6d1e46202afc4d9806c69e416a8b1675e1585a Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:52:29 +1200
Subject: [PATCH 06/21] remove volume per volume, not a type that has any
associated units
---
packages/evo-blockmodels/src/evo/blockmodels/typed/units.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index b7f2ddf5..cfce63de 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -44,7 +44,6 @@ class UnitType(Enum):
VALUE = "VALUE"
CATEGORY = "CATEGORY"
ANGLE = "ANGLE"
- VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
@dataclass(frozen=True)
From ec59a34766378415e6b7d49bb49abd9ed89cd2ac Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:56:03 +1200
Subject: [PATCH 07/21] order units alphabetically by group
---
.../src/evo/blockmodels/typed/units.py | 50 +++++++++----------
1 file changed, 25 insertions(+), 25 deletions(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index cfce63de..21fee8aa 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -89,64 +89,64 @@ class Units(Enum):
"""
# Mass per mass (grade) units
- PERCENT = "%[mass]"
- PARTS_PER_MILLION = "ppm[mass]"
+ CARATS_PER_HUNDRED_TONNE = "0.01 ct/t"
+ CARATS_PER_TONNE = "ct/t"
GRAMS_PER_TONNE = "g/t"
+ MICROGRAMS_PER_GRAM = "ug/g"
+ MICROGRAMS_PER_KILOGRAM = "ug/kg"
MILLIGRAMS_PER_GRAM = "mg/g"
MILLIGRAMS_PER_KILOGRAM = "mg/kg"
- MICROGRAMS_PER_GRAM = "ug/g"
- CARATS_PER_HUNDRED_TONNE = "0.01 ct/t"
PARTS_PER_BILLION = "ppb[mass]"
+ PARTS_PER_MILLION = "ppm[mass]"
+ PERCENT = "%[mass]"
TROY_OUNCES_PER_SHORT_TON = "oz t/ton[US]"
- CARATS_PER_TONNE = "ct/t"
- MICROGRAMS_PER_KILOGRAM = "ug/kg"
# Mass per volume (density) units
- KILOS_PER_CUBIC_METRE = "kg/m3"
GRAMS_PER_CUBIC_CENTIMETRE = "g/cm3"
+ KILOS_PER_CUBIC_METRE = "kg/m3"
POUNDS_PER_CUBIC_FOOT = "lbm/ft3"
- TONNES_PER_CUBIC_METRE = "t/m3"
SHORT_TON_PER_CUBIC_FOOT = "ton[US]/ft3"
+ TONNES_PER_CUBIC_METRE = "t/m3"
# Dimensionless units
- PERCENTAGE = "%"
COUNT = "count"
+ PERCENTAGE = "%"
RATIO = "ratio"
# Mass units
CARATS = "ct"
GRAMS = "g"
- POUNDS = "lbm"
- TROY_OUNCES = "ozm[troy]"
- TONNES = "t"
+ KILOGRAMS = "kg"
KILOTONNES = "kt"
MEGATONNES = "Mt"
- SHORT_TONS = "ton[US]"
- THOUSAND_SHORT_TONS = "kton[US]"
- MILLION_SHORT_TONS = "Mton[US]"
- KILOGRAMS = "kg"
- THOUSAND_POUNDS = "klbm"
- MILLIGRAMS = "mg"
MICROGRAMS = "ug"
+ MILLIGRAMS = "mg"
+ MILLION_POUNDS = "Mlbm"
+ MILLION_SHORT_TONS = "Mton[US]"
+ MILLION_TROY_OUNCES = "1000000 ozm[troy]"
+ POUNDS = "lbm"
+ SHORT_TONS = "ton[US]"
THOUSAND_CARATS = "1000 ct"
+ THOUSAND_POUNDS = "klbm"
+ THOUSAND_SHORT_TONS = "kton[US]"
THOUSAND_TROY_OUNCES = "1000 ozm[troy]"
- MILLION_TROY_OUNCES = "1000000 ozm[troy]"
- MILLION_POUNDS = "Mlbm"
+ TONNES = "t"
+ TROY_OUNCES = "ozm[troy]"
# Length units
- METRES = "m"
- FEET = "ft"
CENTIMETRES = "cm"
+ FEET = "ft"
+ METRES = "m"
# Volume units
CUBIC_CENTIMETRES = "cm3"
- CUBIC_METRES = "m3"
CUBIC_FEET = "ft3"
+ CUBIC_METRES = "m3"
# Value units
- DOLLARS_PER_TONNE = "$/t"
- DOLLARS_PER_SHORT_TON = "$/ton[US]"
DOLLARS = "$"
+ DOLLARS_PER_SHORT_TON = "$/ton[US]"
+ DOLLARS_PER_TONNE = "$/t"
# Angle units
DEGREES = "dega"
From 58bfe75a3df692bf2e3704827baea96a68291ffe Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:01:04 +1200
Subject: [PATCH 08/21] use str enum to maintain compatability with sdk usage
---
packages/evo-blockmodels/src/evo/blockmodels/typed/units.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index 21fee8aa..ba154672 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -66,7 +66,7 @@ class UnitInfo:
"""Conversion factor to the reference unit for this unit type."""
-class Units(Enum):
+class Units(str, Enum):
"""Common unit IDs for block model attributes.
These are the most commonly used unit IDs. For a complete list,
From 6086d3d373c2c3661c356df71c6f50eade5027a5 Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:02:50 +1200
Subject: [PATCH 09/21] add category unit
---
packages/evo-blockmodels/src/evo/blockmodels/typed/units.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
index ba154672..84cd4cdc 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py
@@ -148,6 +148,9 @@ class Units(str, Enum):
DOLLARS_PER_SHORT_TON = "$/ton[US]"
DOLLARS_PER_TONNE = "$/t"
+ # Category units
+ NUMERIC_CATEGORY = "numeric_category"
+
# Angle units
DEGREES = "dega"
RADIANS = "rad"
From afae110ef3ae63d4ba092330345261532038ddc6 Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:20:22 +1200
Subject: [PATCH 10/21] remove volume per volume from other unit type
definition
---
packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
index c910fac0..efdbbb35 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/endpoints/models.py
@@ -954,7 +954,6 @@ class UnitType(Enum):
VALUE = "VALUE"
CATEGORY = "CATEGORY"
ANGLE = "ANGLE"
- VOLUME_PER_VOLUME = "VOLUME_PER_VOLUME"
class UpdateMetadataValues(CustomBaseModel):
From d57fe77d335bd1f537e09a430b2e53c7ba78cfde Mon Sep 17 00:00:00 2001
From: Jamie Spyker <140359979+jamie-spyker-sqnt@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:31:53 +1200
Subject: [PATCH 11/21] fix package imports
---
packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
index 62808ec8..c0bf284a 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py
@@ -22,6 +22,7 @@
ReportResult,
ReportSpecificationData,
)
+from .types import BoundingBox, Point3, Size3d, Size3i
from .units import UnitInfo, Units, UnitType, get_available_units
__all__ = [
From 7e1939738d9c5b4ff89361a84b24ceba3ca14d57 Mon Sep 17 00:00:00 2001
From: "Adam Freeman (Seequent)"
<236270478+AdamFreemanSeequent@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:30:38 +1200
Subject: [PATCH 12/21] Update comment to match code (#340)
## Description
Very minor change to a comment which does not match what the code is
doing
## Checklist
- [x] I have read the contributing guide and the code of conduct
---
code-samples/blockmodels/sdk-examples.ipynb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/code-samples/blockmodels/sdk-examples.ipynb b/code-samples/blockmodels/sdk-examples.ipynb
index 97ae9341..90377dbb 100644
--- a/code-samples/blockmodels/sdk-examples.ipynb
+++ b/code-samples/blockmodels/sdk-examples.ipynb
@@ -251,7 +251,7 @@
" \"x\": current_df[\"x\"].tolist(),\n",
" \"y\": current_df[\"y\"].tolist(),\n",
" \"z\": current_df[\"z\"].tolist(),\n",
- " # Add new column Li and Fe columns\n",
+ " # Add new column Fe and Pb columns\n",
" \"Fe\": rng.uniform(0.0, 5.0, size=len(current_df)).tolist(),\n",
" \"Pb\": rng.uniform(0.0, 5.0, size=len(current_df)).tolist(),\n",
" # Update an existing column\n",
From a67aa3cac6ffa82a2222de1f2120b5154920fd4d Mon Sep 17 00:00:00 2001
From: Tim Hamblin <57604968+tim-hamblin-seequent@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:16:23 +1200
Subject: [PATCH 13/21] Expose groups, add test coverage. (#329)
## Description
Return the group information from the version and version listing
responses
## Checklist
- [x] I have read the contributing guide and the code of conduct
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
packages/evo-blockmodels/pyproject.toml | 2 +-
.../src/evo/blockmodels/client.py | 2 +
.../src/evo/blockmodels/data.py | 23 +-
.../evo-blockmodels/tests/test_data_repr.py | 2 +
.../tests/test_list_block_models.py | 208 +++++++++++++++++-
.../tests/test_typed_regular_block_model.py | 5 +
uv.lock | 2 +-
7 files changed, 239 insertions(+), 5 deletions(-)
diff --git a/packages/evo-blockmodels/pyproject.toml b/packages/evo-blockmodels/pyproject.toml
index 1222596d..ee2f5e3d 100644
--- a/packages/evo-blockmodels/pyproject.toml
+++ b/packages/evo-blockmodels/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "evo-blockmodels"
description = "Python SDK for using the Seequent Evo Geoscience Block Model API"
-version = "0.5.2"
+version = "0.6.0"
requires-python = ">=3.10"
license-files = ["LICENSE.md"]
dynamic = ["readme"]
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/client.py b/packages/evo-blockmodels/src/evo/blockmodels/client.py
index 32f6ee74..32dec066 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/client.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/client.py
@@ -94,6 +94,7 @@ def _version_from_model(version: models.Version | models.VersionWithChanges) ->
base_version_id=version.base_version_id,
parent_version_id=version.parent_version_id,
columns=version.mapping.columns,
+ groups=version.mapping.groups,
geoscience_version_id=version.geoscience_version_id,
)
@@ -110,6 +111,7 @@ def _version_listing_from_model(version: models.ListingVersion) -> ListingVersio
base_version_id=version.base_version_id,
parent_version_id=version.parent_version_id,
columns=version.mapping.columns,
+ groups=version.mapping.groups,
geoscience_version_id=version.geoscience_version_id,
)
diff --git a/packages/evo-blockmodels/src/evo/blockmodels/data.py b/packages/evo-blockmodels/src/evo/blockmodels/data.py
index ca06eb35..c09e9473 100644
--- a/packages/evo-blockmodels/src/evo/blockmodels/data.py
+++ b/packages/evo-blockmodels/src/evo/blockmodels/data.py
@@ -18,7 +18,15 @@
from evo.workspaces import ServiceUser
from ._model_config import CustomBaseModel
-from .endpoints.models import BBox, BBoxXYZ, Column, ListingColumn, RotationAxis
+from .endpoints.models import (
+ BBox,
+ BBoxXYZ,
+ Column,
+ ListingColumn,
+ ListingGroup,
+ ResolvedGroup,
+ RotationAxis,
+)
__all__ = [
"BaseGridDefinition",
@@ -28,9 +36,11 @@
"FlexibleGridDefinition",
"FullySubBlockedGridDefinition",
"ListingColumn",
+ "ListingGroup",
"ListingVersion",
"OctreeGridDefinition",
"RegularGridDefinition",
+ "ResolvedGroup",
"Version",
]
@@ -277,6 +287,11 @@ class Version(_VersionBase):
Columns within this version, each carrying its ``tags``.
"""
+ groups: list[ResolvedGroup]
+ """
+ Column groups within this version, each carrying its missing-column policy and its ``tags``.
+ """
+
@dataclass(frozen=True, kw_only=True, repr=False)
class ListingVersion(_VersionBase):
@@ -285,3 +300,9 @@ class ListingVersion(_VersionBase):
Column summaries for a listed version. These never carry ``tags`` — fetch the version
individually (e.g. via :meth:`BlockModelAPIClient.get_version`) to get tags.
"""
+
+ groups: list[ListingGroup]
+ """
+ Group summaries for a listed version. These never carry ``tags`` — fetch the version
+ individually (e.g. via :meth:`BlockModelAPIClient.get_version`) to get tags.
+ """
diff --git a/packages/evo-blockmodels/tests/test_data_repr.py b/packages/evo-blockmodels/tests/test_data_repr.py
index 34fda82b..e63d100b 100644
--- a/packages/evo-blockmodels/tests/test_data_repr.py
+++ b/packages/evo-blockmodels/tests/test_data_repr.py
@@ -74,6 +74,7 @@ def _create_test_version(
comment=with_comment,
bbox=bbox,
columns=columns,
+ groups=[],
)
def test_repr_returns_concise_string(self) -> None:
@@ -113,6 +114,7 @@ def test_repr_with_email_fallback(self) -> None:
comment="",
bbox=None,
columns=columns,
+ groups=[],
)
repr_str = repr(version)
self.assertIn("by=test@example.com", repr_str)
diff --git a/packages/evo-blockmodels/tests/test_list_block_models.py b/packages/evo-blockmodels/tests/test_list_block_models.py
index 240e198c..c211a7e7 100644
--- a/packages/evo-blockmodels/tests/test_list_block_models.py
+++ b/packages/evo-blockmodels/tests/test_list_block_models.py
@@ -5,6 +5,7 @@
from evo.blockmodels import BlockModelAPIClient
from evo.blockmodels.data import ListingVersion, RegularGridDefinition
+from evo.blockmodels.endpoints.models import MissingColumnPolicy
from evo.common import Environment
from evo.common.test_tools import (
BASE_URL,
@@ -53,7 +54,7 @@ def make_bm(self, name: str):
"workspace_id": str(uuid4()),
}
- def make_version(self, version_id: int, version_uuid: str):
+ def make_version(self, version_id: int, version_uuid: str, mapping: dict | None = None):
return json.loads(
json.dumps(
{
@@ -67,11 +68,30 @@ def make_version(self, version_id: int, version_uuid: str):
"base_version_id": None,
"parent_version_id": version_id - 1 if version_id > 1 else None,
"geoscience_version_id": str(version_id),
- "mapping": {"columns": []},
+ "mapping": mapping if mapping is not None else {"columns": []},
}
)
)
+ @staticmethod
+ def make_group(
+ group_uuid: str,
+ title: str,
+ parent_group_uuid: str | None = None,
+ is_hidden: bool = False,
+ tags: dict | None = None,
+ ) -> dict:
+ group = {
+ "group_uuid": group_uuid,
+ "title": title,
+ "parent_group_uuid": parent_group_uuid,
+ "is_hidden": is_hidden,
+ "resolved_missing_column_policy": "USE_PREVIOUS",
+ }
+ if tags is not None:
+ group["tags"] = tags
+ return group
+
async def test_list_block_models_converts_endpoint_models_to_dataclass(self) -> None:
# Prepare a fake endpoint BlockModel
endpoint_bm = self.make_bm("Test BM")
@@ -272,3 +292,187 @@ async def test_list_all_block_models_deleted_parameter(self) -> None:
result = await self.client.list_all_block_models(deleted=True)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "deleted-bm")
+
+ async def test_list_versions_returns_listing_groups(self) -> None:
+ bm_id = uuid.uuid4()
+ parent_uuid = str(uuid.uuid4())
+ child_uuid = str(uuid.uuid4())
+ version = self.make_version(
+ 1,
+ str(uuid.uuid4()),
+ mapping={
+ "columns": [],
+ "groups": [
+ self.make_group(parent_uuid, "Assays"),
+ self.make_group(child_uuid, "Internal", parent_group_uuid=parent_uuid, is_hidden=True),
+ ],
+ },
+ )
+ with self.transport.set_http_response(
+ 200,
+ json.dumps(
+ {"count": 1, "limit": 100, "offset": 0, "results": [version], "total": 1, "referenced_units": []}
+ ),
+ headers={"Content-Type": "application/json"},
+ ):
+ result = await self.client.list_versions(bm_id)
+
+ groups = result[0].groups
+ self.assertEqual(len(groups), 2)
+ self.assertEqual(str(groups[0].group_uuid), parent_uuid)
+ self.assertEqual(groups[0].title, "Assays")
+ self.assertIsNone(groups[0].parent_group_uuid)
+ self.assertFalse(groups[0].is_hidden)
+ self.assertEqual(str(groups[1].group_uuid), child_uuid)
+ self.assertEqual(groups[1].title, "Internal")
+ self.assertEqual(str(groups[1].parent_group_uuid), parent_uuid)
+ self.assertTrue(groups[1].is_hidden)
+
+ async def test_get_version_returns_resolved_groups_with_tags(self) -> None:
+ bm_id = uuid.uuid4()
+ version_uuid = uuid.uuid4()
+ group_uuid = str(uuid.uuid4())
+ version = self.make_version(
+ 2,
+ str(version_uuid),
+ mapping={
+ "columns": [],
+ "groups": [
+ {
+ "group_uuid": group_uuid,
+ "title": "Assays",
+ "parent_group_uuid": None,
+ "is_hidden": False,
+ "missing_column_policy": "REJECT",
+ "resolved_missing_column_policy": "REJECT",
+ "tags": {"source": "assay"},
+ }
+ ],
+ },
+ )
+ with self.transport.set_http_response(
+ 200,
+ json.dumps(version),
+ headers={"Content-Type": "application/json"},
+ ):
+ result = await self.client.get_version(bm_id, version_uuid)
+
+ self.assertEqual(len(result.groups), 1)
+ self.assertEqual(str(result.groups[0].group_uuid), group_uuid)
+ self.assertEqual(result.groups[0].tags, {"source": "assay"})
+ self.assertEqual(result.groups[0].resolved_missing_column_policy, MissingColumnPolicy.REJECT)
+
+ async def test_list_versions_preserves_column_group_assignment(self) -> None:
+ bm_id = uuid.uuid4()
+ group_uuid = str(uuid.uuid4())
+ version = self.make_version(
+ 1,
+ str(uuid.uuid4()),
+ mapping={
+ "columns": [
+ {
+ "col_id": str(uuid.uuid4()),
+ "data_type": "Float64",
+ "title": "Au",
+ "unit_id": "g/t",
+ "group_uuid": group_uuid,
+ },
+ {"col_id": str(uuid.uuid4()), "data_type": "Float64", "title": "Ag", "unit_id": "g/t"},
+ ],
+ "groups": [self.make_group(group_uuid, "Assays")],
+ },
+ )
+ with self.transport.set_http_response(
+ 200,
+ json.dumps(
+ {"count": 1, "limit": 100, "offset": 0, "results": [version], "total": 1, "referenced_units": []}
+ ),
+ headers={"Content-Type": "application/json"},
+ ):
+ result = await self.client.list_versions(bm_id)
+
+ columns = result[0].columns
+ self.assertEqual(str(columns[0].group_uuid), group_uuid)
+ self.assertIsNone(columns[1].group_uuid)
+
+ async def test_get_version_preserves_column_group_assignment(self) -> None:
+ bm_id = uuid.uuid4()
+ version_uuid = uuid.uuid4()
+ group_uuid = str(uuid.uuid4())
+ version = self.make_version(
+ 1,
+ str(version_uuid),
+ mapping={
+ "columns": [
+ {
+ "col_id": str(uuid.uuid4()),
+ "data_type": "Float64",
+ "title": "Au",
+ "unit_id": "g/t",
+ "group_uuid": group_uuid,
+ },
+ {"col_id": str(uuid.uuid4()), "data_type": "Float64", "title": "Ag", "unit_id": "g/t"},
+ ],
+ "groups": [self.make_group(group_uuid, "Assays")],
+ },
+ )
+ with self.transport.set_http_response(
+ 200,
+ json.dumps(version),
+ headers={"Content-Type": "application/json"},
+ ):
+ result = await self.client.get_version(bm_id, version_uuid)
+
+ self.assertEqual(str(result.columns[0].group_uuid), group_uuid)
+ self.assertIsNone(result.columns[1].group_uuid)
+
+ async def test_get_version_without_groups_returns_empty_groups(self) -> None:
+ bm_id = uuid.uuid4()
+ version_uuid = uuid.uuid4()
+ version = self.make_version(
+ 1,
+ str(version_uuid),
+ mapping={
+ "columns": [{"col_id": str(uuid.uuid4()), "data_type": "Float64", "title": "Au", "unit_id": "g/t"}]
+ },
+ )
+ with self.transport.set_http_response(
+ 200,
+ json.dumps(version),
+ headers={"Content-Type": "application/json"},
+ ):
+ result = await self.client.get_version(bm_id, version_uuid)
+
+ self.assertEqual(result.groups, [])
+
+ async def test_list_all_versions_preserves_groups_across_pages(self) -> None:
+ bm_id = uuid.uuid4()
+ group_uuid = str(uuid.uuid4())
+ v2 = self.make_version(2, str(uuid.uuid4()))
+ v1 = self.make_version(
+ 1,
+ str(uuid.uuid4()),
+ mapping={"columns": [], "groups": [self.make_group(group_uuid, "Assays")]},
+ )
+ self.transport.request.side_effect = [
+ MockResponse(
+ status_code=200,
+ content=json.dumps(
+ {"count": 1, "limit": 1, "offset": 0, "results": [v2], "total": 2, "referenced_units": []}
+ ),
+ headers={"Content-Type": "application/json"},
+ ),
+ MockResponse(
+ status_code=200,
+ content=json.dumps(
+ {"count": 1, "limit": 1, "offset": 1, "results": [v1], "total": 2, "referenced_units": []}
+ ),
+ headers={"Content-Type": "application/json"},
+ ),
+ ]
+
+ result = await self.client.list_all_versions(bm_id, page_limit=1)
+ self.assertEqual(result[0].groups, [])
+ self.assertEqual(len(result[1].groups), 1)
+ self.assertEqual(str(result[1].groups[0].group_uuid), group_uuid)
+ self.assertEqual(result[1].groups[0].title, "Assays")
diff --git a/packages/evo-blockmodels/tests/test_typed_regular_block_model.py b/packages/evo-blockmodels/tests/test_typed_regular_block_model.py
index a91b2a8b..34ee7d64 100644
--- a/packages/evo-blockmodels/tests/test_typed_regular_block_model.py
+++ b/packages/evo-blockmodels/tests/test_typed_regular_block_model.py
@@ -406,6 +406,7 @@ def _create_version(self, version_id: int, version_uuid: uuid.UUID):
base_version_id=None if version_id == 1 else version_id - 1,
parent_version_id=version_id - 1,
columns=[],
+ groups=[],
geoscience_version_id=str(version_id + 1),
)
@@ -531,6 +532,7 @@ async def test_update_attributes(self) -> None:
base_version_id=None,
parent_version_id=0,
columns=[],
+ groups=[],
geoscience_version_id="2",
)
cell_data = pd.DataFrame(
@@ -656,6 +658,7 @@ def _make_block_model_instance(context, client):
models.Column(col_id=str(uuid.uuid4()), title="Au", data_type=models.DataType.Float64),
models.Column(col_id=str(uuid.uuid4()), title="density", data_type=models.DataType.Float64),
],
+ groups=[],
geoscience_version_id="2",
)
cell_data = pd.DataFrame(
@@ -981,6 +984,7 @@ def test_get_column_id_map_with_invalid_uuid(self) -> None:
models.Column(col_id="i", title="i_idx", data_type=models.DataType.UInt32),
models.Column(col_id=str(uuid.uuid4()), title="Au", data_type=models.DataType.Float64),
],
+ groups=[],
geoscience_version_id="2",
)
@@ -1137,6 +1141,7 @@ def test_get_context_builds_from_client_when_none(self) -> None:
base_version_id=None,
parent_version_id=0,
columns=[],
+ groups=[],
geoscience_version_id="2",
)
diff --git a/uv.lock b/uv.lock
index 13c4b50e..80f8210f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -844,7 +844,7 @@ wheels = [
[[package]]
name = "evo-blockmodels"
-version = "0.5.2"
+version = "0.6.0"
source = { editable = "packages/evo-blockmodels" }
dependencies = [
{ name = "evo-sdk-common" },
From a4be6f1c5913856a7d38cf2dd52ab4de2c9912d7 Mon Sep 17 00:00:00 2001
From: David Knight <129003925+davidknight-seequent@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:02:12 +1200
Subject: [PATCH 14/21] Blockmodel updates aug 2026 (#339)
## Description
- Reorganize block model samples into a guided SDK tutorial path, with
direct API workflows separated as advanced reference material.
- Add an SDK tutorial for transforming existing block model attributes,
shared notebook selector helpers, and scoped sample data.
- Refresh notebook compatibility/linting updates and expand the
supported uv version range.
## Checklist
- [x] I have read the contributing guide and the code of conduct
---
code-samples/README.md | 21 +-
.../auth-and-evo-discovery/api-examples.ipynb | 4 +-
.../auth-and-evo-discovery/sdk-examples.ipynb | 12 +-
code-samples/auth_helper.py | 10 +-
code-samples/blockmodels/README.md | 31 +
code-samples/blockmodels/api/README.md | 10 +
...block-model-api-reference-workflows.ipynb} | 41 +-
.../{ => api/data}/sample-data/data.parquet | Bin
.../data}/sample-data/data_update.parquet | Bin
.../blockmodels/api/downloads/.gitignore | 3 +
...reate-and-query-regular-block-model.ipynb} | 49 +-
...sform-existing-block-model-attribute.ipynb | 343 ++
.../03-block-model-reports.ipynb} | 38 +-
.../blockmodels/sdk/advanced/README.md | 12 +
.../sdk/block_model_selector_widgets.py | 94 +
.../sdk/data/sample-data/data.parquet | Bin 0 -> 3899150 bytes
.../sdk/data/sample-data/data_update.parquet | Bin 0 -> 5240 bytes
.../common-tasks/working-with-parquet.ipynb | 6 +-
code-samples/files/sdk-examples.ipynb | 2 +-
.../sdk-examples.ipynb | 8 +-
.../geoscience-objects/helpers/evo_common.py | 2 +-
.../publish-regular-2d-grid.ipynb | 2 +-
code-samples/tests/test_notebooks.py | 4 +-
code-samples/uv.lock | 2789 ++++++++++-------
.../bonus/move-objects/move-objects.ipynb | 2 +-
.../bonus/move-objects/workspace_utils.py | 12 +-
pyproject.toml | 2 +-
27 files changed, 2194 insertions(+), 1303 deletions(-)
create mode 100644 code-samples/blockmodels/README.md
create mode 100644 code-samples/blockmodels/api/README.md
rename code-samples/blockmodels/{api-examples.ipynb => api/block-model-api-reference-workflows.ipynb} (96%)
rename code-samples/blockmodels/{ => api/data}/sample-data/data.parquet (100%)
rename code-samples/blockmodels/{ => api/data}/sample-data/data_update.parquet (100%)
create mode 100644 code-samples/blockmodels/api/downloads/.gitignore
rename code-samples/blockmodels/{sdk-examples.ipynb => sdk/01-create-and-query-regular-block-model.ipynb} (84%)
create mode 100644 code-samples/blockmodels/sdk/02-transform-existing-block-model-attribute.ipynb
rename code-samples/blockmodels/{reports.ipynb => sdk/03-block-model-reports.ipynb} (90%)
create mode 100644 code-samples/blockmodels/sdk/advanced/README.md
create mode 100644 code-samples/blockmodels/sdk/block_model_selector_widgets.py
create mode 100644 code-samples/blockmodels/sdk/data/sample-data/data.parquet
create mode 100644 code-samples/blockmodels/sdk/data/sample-data/data_update.parquet
diff --git a/code-samples/README.md b/code-samples/README.md
index 0b462455..bb2f9bfa 100644
--- a/code-samples/README.md
+++ b/code-samples/README.md
@@ -164,8 +164,8 @@ Before diving into the geoscience code samples, we recommend that you first test
a. Click to open **auth-and-evo-discovery**.
-b. Click to open **native-app-token.ipynb**.
-c. Enter your **client ID** and **callback URL** from when you registered your Evo app.
+b. Click to open **sdk-examples.ipynb**.
+c. Enter your **client ID** and **callback URL** from when you created your Evo app.
@@ -175,7 +175,7 @@ e. Run the next code cell. You may see red warning text - this is normal.
f. If everything is set up correctly, your web browser should open a new tab and ask you to sign in with your Bentley ID. Once you have signed in and granted access to the app, return to the tab with Jupyter running.
-g. Using the widget that appears under the code cell, select your Organisation, Hub and Workspace.
+g. Using the widget that appears under the code cell, select your Evo instance and workspace.
h. Run the next code cell. You should see a printout of your Evo hub URL, organisation ID and workspace ID.
@@ -195,15 +195,14 @@ Learn about Evo auth and the Evo discovery service:
### 🧊 Block models
**📁 [blockmodels](blockmodels/)**
-Block model workflow examples using both SDK and direct API approaches:
-- `sdk-examples.ipynb` - High-level Block Model SDK examples (recommended)
-- `api-examples.ipynb` - Direct API calls for advanced use cases
+Start with the [Block Model SDK tutorials](blockmodels/README.md), which provide the recommended learning path for creating, querying, and managing block models.
-These notebooks demonstrate:
-- Creating regular block models
-- Adding and updating columns
-- Querying data with bounding boxes
-- Working with PyArrow tables and pandas DataFrames
+- `sdk/01-create-and-query-regular-block-model.ipynb` - Create a regular block model, add columns, and query its data
+- `sdk/02-transform-existing-block-model-attribute.ipynb` - Transform an existing block model attribute and publish the result as a new attribute
+- `sdk/03-block-model-reports.ipynb` - Create resource-estimation reports from a block model
+- `api/block-model-api-reference-workflows.ipynb` - Advanced direct API reference for debugging and unsupported workflows
+
+The SDK tutorials use high-level Evo interfaces with PyArrow integration. The API reference shows raw service requests, responses, and asynchronous job handling when direct control is required.
### 🗂️ File operations
**📁 [files](files/)**
diff --git a/code-samples/auth-and-evo-discovery/api-examples.ipynb b/code-samples/auth-and-evo-discovery/api-examples.ipynb
index afe26d2c..e0a04efa 100644
--- a/code-samples/auth-and-evo-discovery/api-examples.ipynb
+++ b/code-samples/auth-and-evo-discovery/api-examples.ipynb
@@ -105,7 +105,7 @@
" )\n",
"\n",
" self.wfile.write(\n",
- " \"\"\"\n",
+ " b\"\"\"\n",
"\n",
"
\n",
" Seequent Evo - Authorization successful\n",
@@ -139,7 +139,7 @@
" \n",
" \n",
"\n",
- "\"\"\".encode(\"UTF-8\")\n",
+ "\"\"\"\n",
" )\n",
"\n",
" with OAuthHttpServer((\"\", port), OAuthHttpHandler) as httpd:\n",
diff --git a/code-samples/auth-and-evo-discovery/sdk-examples.ipynb b/code-samples/auth-and-evo-discovery/sdk-examples.ipynb
index ac97ddea..8f945ab0 100644
--- a/code-samples/auth-and-evo-discovery/sdk-examples.ipynb
+++ b/code-samples/auth-and-evo-discovery/sdk-examples.ipynb
@@ -68,8 +68,8 @@
"# Read the authenticated environment from the session manager and print the\n",
"# core identifiers you can use in later SDK or API examples.\n",
"environment = manager.get_environment()\n",
- "print(f\"Hub URL: {environment.hub_url}\")\n",
- "print(f\"Org ID: {environment.org_id}\")\n",
+ "print(f\"Evo hub URL: {environment.hub_url}\")\n",
+ "print(f\"Evo instance ID: {environment.org_id}\")\n",
"print(f\"Workspace ID: {environment.workspace_id}\")"
]
},
@@ -132,7 +132,7 @@
"source": [
"### Discovery API client\n",
"\n",
- "Use the Discovery API client to obtain details about your Evo instances/organizations, hubs and available services."
+ "Use the Discovery API client to obtain details about your Evo instances (previously called organizations), hubs and available services."
]
},
{
@@ -149,9 +149,9 @@
" discovery_client = DiscoveryAPIClient(api_connector)\n",
" organizations = await discovery_client.list_organizations()\n",
"\n",
- "# Select the first organization for this example\n",
- "selected_organization = organizations[0]\n",
- "print(\"Selected organization:\", selected_organization)"
+ "# Select the first Evo instance for this example\n",
+ "selected_evo_instance = organizations[0]\n",
+ "print(\"Selected Evo instance:\", selected_evo_instance)"
]
}
],
diff --git a/code-samples/auth_helper.py b/code-samples/auth_helper.py
index 5184c29a..ac25b02f 100644
--- a/code-samples/auth_helper.py
+++ b/code-samples/auth_helper.py
@@ -34,11 +34,11 @@ class _CIManager:
def __init__(
self,
- connector: "APIConnector",
+ connector: APIConnector,
hub_url: str,
org_id: str,
workspace_id: str | None = None,
- cache: "ICache | None" = None,
+ cache: ICache | None = None,
) -> None:
self._connector = connector
self._hub_url = hub_url
@@ -46,7 +46,7 @@ def __init__(
self._workspace_id = UUID(workspace_id) if workspace_id else None
self._cache = cache
- def get_connector(self) -> "APIConnector":
+ def get_connector(self) -> APIConnector:
return self._connector
def get_environment(self) -> _Environment:
@@ -59,11 +59,11 @@ def get_environment(self) -> _Environment:
def get_org_id(self) -> UUID:
return self._org_id
- def get_cache(self) -> "ICache | None":
+ def get_cache(self) -> ICache | None:
return self._cache
@property
- def cache(self) -> "ICache | None":
+ def cache(self) -> ICache | None:
return self._cache
diff --git a/code-samples/blockmodels/README.md b/code-samples/blockmodels/README.md
new file mode 100644
index 00000000..ad94bd2e
--- /dev/null
+++ b/code-samples/blockmodels/README.md
@@ -0,0 +1,31 @@
+# Block Model Samples
+
+The Block Model SDK tutorials are the recommended path for creating, querying, and managing Evo block models. They use the high-level `evo.blockmodels` interfaces and PyArrow tables.
+
+## Start Here
+
+1. [01: Create and Query a Regular Block Model](sdk/01-create-and-query-regular-block-model.ipynb)
+ - Create a regular block model, add columns and units, and query all data or a bounding box.
+2. [02: Transform an Existing Block Model Attribute](sdk/02-transform-existing-block-model-attribute.ipynb)
+ - Load an existing block model, transform a numeric attribute, and publish the result as a new attribute.
+3. [03: Block Model Reports](sdk/03-block-model-reports.ipynb)
+ - Create a report-ready block model and calculate grouped resource-estimation summaries.
+
+## Future SDK Tutorials
+
+The SDK tutorial sequence will expand here as new workflows are added:
+
+- Schema and metadata management
+- Versions and change tracking
+- Query export and cache-backed workflows
+- [Advanced grid workflows](sdk/advanced/README.md), including subblocked and octree block models
+
+## Direct API Reference
+
+[Advanced: Block Model API Reference Workflows](api/block-model-api-reference-workflows.ipynb) contains direct service calls, job polling, and request/response payloads. Use it for debugging, direct API integrations, and functionality not yet supported by the high-level SDK.
+
+## Tutorial Data
+
+- `api/data/sample-data/` contains input files for the direct API reference tutorial.
+- `sdk/data/sample-data/` contains input files for the SDK tutorials.
+- `api/downloads/` stores generated query results from the direct API reference.
diff --git a/code-samples/blockmodels/api/README.md b/code-samples/blockmodels/api/README.md
new file mode 100644
index 00000000..faf6bd19
--- /dev/null
+++ b/code-samples/blockmodels/api/README.md
@@ -0,0 +1,10 @@
+# Block Model Direct API Reference
+
+This directory contains advanced examples that call the Block Model service directly. These notebooks are not the recommended starting point for application development.
+
+Begin with the [SDK tutorials](../README.md) for high-level operations, PyArrow integration, validation, and reduced request-management code.
+
+Use [Block Model API Reference Workflows](block-model-api-reference-workflows.ipynb) when you need to inspect raw requests and responses, debug an integration, or use a service capability that the SDK does not yet expose.
+The request and response patterns in these examples can also be adapted for applications written in languages other than Python.
+
+Generated query results are written to `downloads/` and are excluded from version control.
diff --git a/code-samples/blockmodels/api-examples.ipynb b/code-samples/blockmodels/api/block-model-api-reference-workflows.ipynb
similarity index 96%
rename from code-samples/blockmodels/api-examples.ipynb
rename to code-samples/blockmodels/api/block-model-api-reference-workflows.ipynb
index 9b9cd930..8264c339 100644
--- a/code-samples/blockmodels/api-examples.ipynb
+++ b/code-samples/blockmodels/api/block-model-api-reference-workflows.ipynb
@@ -4,27 +4,26 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Block Model API Examples\n",
+ "# Block Model API Reference Workflows\n",
"\n",
- "This notebook demonstrates how to work with Evo Block Models using **direct API calls**, providing granular control over block model operations and detailed insight into the underlying service interactions.\n",
+ "This notebook demonstrates direct Block Model service calls. It is an advanced reference for inspecting request and response details, debugging integrations, and workflows that are not yet available through the high-level SDK.\n",
"\n",
- "### API vs SDK Approach\n",
+ "## Recommended Path\n",
"\n",
- "This notebook uses **direct Block Model API calls** which:\n",
- "- ✅ Provides full control over API requests and responses\n",
- "- ✅ Shows detailed API response structures and metadata\n",
- "- ✅ Allows for advanced customization and fine-tuning\n",
- "- ✅ Helps understand the underlying service architecture\n",
- "- ✅ Useful for debugging and advanced use cases\n",
+ "Start with the SDK tutorials:\n",
"\n",
- "### Want a Simpler, High-Level Interface?\n",
+ "1. [01: Create and Query a Regular Block Model](../sdk/01-create-and-query-regular-block-model.ipynb)\n",
+ "2. [02: Transform an Existing Block Model Attribute](../sdk/02-transform-existing-block-model-attribute.ipynb)\n",
+ "3. [03: Block Model Reports](../sdk/03-block-model-reports.ipynb)\n",
"\n",
- "If you prefer a more streamlined experience with less boilerplate code, check out the `sdk-examples.ipynb` notebook in this same directory. The SDK examples show:\n",
- "- Simplified method calls using `BlockModelAPIClient`\n",
- "- Automatic handling of complex API interactions\n",
- "- Direct integration with PyArrow tables and pandas DataFrames\n",
- "- Better error handling and validation\n",
- "- Recommended for most common use cases"
+ "The SDK uses `evo.blockmodels.BlockModelAPIClient` to simplify common operations, provide PyArrow integration, and handle service interactions. Use this notebook when direct API control is specifically needed.\n",
+ "\n",
+ "## What This Reference Covers\n",
+ "\n",
+ "- Raw request and response payloads\n",
+ "- Asynchronous job polling and upload URLs\n",
+ "- Column updates, metadata, rename, and deletion workflows\n",
+ "- Downloading query results"
]
},
{
@@ -35,11 +34,11 @@
"source": [
"from evo.notebooks import ServiceManagerWidget\n",
"\n",
- "input_location = \"sample-data\"\n",
+ "input_location = \"data/sample-data\"\n",
"\n",
"# Evo app credentials\n",
- "client_id = \"\" # Replace with your client ID\n",
- "redirect_url = \"\" # Replace with your redirect URL\n",
+ "client_id = \"your-client-id\" # Replace with your client ID\n",
+ "redirect_url = \"your-redirect-url\" # Replace with your redirect URL\n",
"\n",
"manager = await ServiceManagerWidget.with_auth_code(\n",
" client_id=client_id,\n",
@@ -62,7 +61,7 @@
"source": [
"import os\n",
"\n",
- "# Create download directories\n",
+ "# Create a directory for API query results.\n",
"download_path = \"downloads\"\n",
"\n",
"if not os.path.exists(download_path):\n",
@@ -274,7 +273,7 @@
"headers = {\"Content-Type\": \"application/binary\", \"x-ms-blob-type\": \"BlockBlob\"}\n",
"\n",
"# Make a PUT request - include the binary data and headers\n",
- "with open(\"sample-data/data.parquet\", \"rb\") as data_stream:\n",
+ "with open(f\"{input_location}/data.parquet\", \"rb\") as data_stream:\n",
" response = requests.put(url=upload_url, data=data_stream.read(), headers=headers)\n",
"\n",
"if response.status_code != HTTPStatus.CREATED:\n",
diff --git a/code-samples/blockmodels/sample-data/data.parquet b/code-samples/blockmodels/api/data/sample-data/data.parquet
similarity index 100%
rename from code-samples/blockmodels/sample-data/data.parquet
rename to code-samples/blockmodels/api/data/sample-data/data.parquet
diff --git a/code-samples/blockmodels/sample-data/data_update.parquet b/code-samples/blockmodels/api/data/sample-data/data_update.parquet
similarity index 100%
rename from code-samples/blockmodels/sample-data/data_update.parquet
rename to code-samples/blockmodels/api/data/sample-data/data_update.parquet
diff --git a/code-samples/blockmodels/api/downloads/.gitignore b/code-samples/blockmodels/api/downloads/.gitignore
new file mode 100644
index 00000000..9906ee2b
--- /dev/null
+++ b/code-samples/blockmodels/api/downloads/.gitignore
@@ -0,0 +1,3 @@
+# Keep this generated-output directory while ignoring downloaded results.
+*
+!.gitignore
diff --git a/code-samples/blockmodels/sdk-examples.ipynb b/code-samples/blockmodels/sdk/01-create-and-query-regular-block-model.ipynb
similarity index 84%
rename from code-samples/blockmodels/sdk-examples.ipynb
rename to code-samples/blockmodels/sdk/01-create-and-query-regular-block-model.ipynb
index 90377dbb..247c62c5 100644
--- a/code-samples/blockmodels/sdk-examples.ipynb
+++ b/code-samples/blockmodels/sdk/01-create-and-query-regular-block-model.ipynb
@@ -4,26 +4,26 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Block Model SDK Examples\n",
+ "# 01: Create and Query a Regular Block Model\n",
"\n",
- "This notebook demonstrates how to work with Evo Block Models using the **Block Model SDK**, which provides a high-level, Pythonic interface for block model operations.\n",
+ "This is the recommended starting point for the Block Model SDK tutorials. It demonstrates the regular block-model workflow using the high-level Python SDK.\n",
"\n",
- "### SDK vs API Approach\n",
+ "## What You Will Learn\n",
"\n",
- "This notebook uses the **Block Model SDK** (`evo.blockmodels.BlockModelAPIClient`) which:\n",
- "- ✅ Provides simplified, high-level methods for common operations\n",
- "- ✅ Handles complex API interactions behind the scenes\n",
- "- ✅ Offers better error handling and validation\n",
- "- ✅ Works directly with PyArrow tables and pandas DataFrames\n",
- "- ✅ Recommended for most use cases\n",
+ "- Create a regular block model from a PyArrow table\n",
+ "- Add columns and units\n",
+ "- Query all blocks or a spatial subset\n",
"\n",
- "### Want to Learn More About the Underlying API?\n",
+ "## Learning Path\n",
"\n",
- "If you're interested in understanding the raw Block Model API calls and more granular control, check out the `api-examples.ipynb` notebook in this same directory. The API examples show:\n",
- "- Direct HTTP API calls to the Block Model service\n",
- "- Lower-level control over requests and responses\n",
- "- Detailed API response structures\n",
- "- Advanced customization options"
+ "1. **This notebook**: create and query a regular block model\n",
+ "2. [02: Transform an Existing Block Model Attribute](02-transform-existing-block-model-attribute.ipynb): transform a numeric attribute and publish it as a new column\n",
+ "3. [03: Block Model Reports](03-block-model-reports.ipynb): create resource-estimation reports from a block model\n",
+ "4. Future SDK tutorials will cover schema management, version history, change tracking, and cache-backed exports.\n",
+ "\n",
+ "## Direct API Reference\n",
+ "\n",
+ "The [advanced API reference notebook](../api/block-model-api-reference-workflows.ipynb) shows direct service calls for debugging and unsupported workflows. Use the SDK tutorials for normal application development."
]
},
{
@@ -35,11 +35,11 @@
"from evo.blockmodels import BlockModelAPIClient\n",
"from evo.notebooks import ServiceManagerWidget\n",
"\n",
- "input_location = \"sample-data\"\n",
+ "input_location = \"data/sample-data\"\n",
"\n",
"# Evo app credentials\n",
- "client_id = \"\" # Replace with your client ID\n",
- "redirect_url = \"\" # Replace with your redirect URL\n",
+ "client_id = \"your-client-id\" # Replace with your client ID\n",
+ "redirect_url = \"your-redirect-url\" # Replace with your redirect URL\n",
"\n",
"manager = await ServiceManagerWidget.with_auth_code(\n",
" client_id=client_id,\n",
@@ -90,7 +90,7 @@
"from evo.blockmodels.data import RegularGridDefinition\n",
"from evo.blockmodels.endpoints.models import RotationAxis\n",
"\n",
- "input_location = \"sample-data\"\n",
+ "input_location = \"data/sample-data\"\n",
"\n",
"name = f\"Regular block model {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}\"\n",
"description = \"This is a description of my regular block model.\"\n",
@@ -286,17 +286,6 @@
"print(\"Updated: Ag, Zn\")\n",
"print(\"Deleted: Geology\")"
]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### More to come...\n",
- "\n",
- "New examples will be added in the future as the Block Model SDK continues to evolve!\n",
- "\n",
- "In the meantime, check out the `api-examples` notebook to see what else the Block Model API is capable of."
- ]
}
],
"metadata": {
diff --git a/code-samples/blockmodels/sdk/02-transform-existing-block-model-attribute.ipynb b/code-samples/blockmodels/sdk/02-transform-existing-block-model-attribute.ipynb
new file mode 100644
index 00000000..96b7e36e
--- /dev/null
+++ b/code-samples/blockmodels/sdk/02-transform-existing-block-model-attribute.ipynb
@@ -0,0 +1,343 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# 02: Transform an Existing Block Model Attribute\n",
+ "\n",
+ "This tutorial signs in to Evo, selects a workspace during sign-in, loads an existing block model, and transforms a numeric attribute. Demo 1 publishes the result as a new attribute; Demo 2 replaces an existing attribute in a new block model version.\n",
+ "\n",
+ "## Before You Start\n",
+ "\n",
+ "You need an Evo app client ID and redirect URL. You also need permission to view and update the selected block model. Each operation creates a new block model version, so the source version is not changed.\n",
+ "\n",
+ "## Learning Path\n",
+ "\n",
+ "1. [01: Create and Query a Regular Block Model](01-create-and-query-regular-block-model.ipynb)\n",
+ "2. **This notebook**: transform an existing numeric attribute\n",
+ "3. [03: Block Model Reports](03-block-model-reports.ipynb)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from evo.notebooks import ServiceManagerWidget\n",
+ "\n",
+ "# Evo app credentials\n",
+ "client_id = \"your-client-id\" # Replace with your client ID\n",
+ "redirect_url = \"your-redirect-url\" # Replace with your redirect URL\n",
+ "\n",
+ "# The sign-in flow includes workspace selection.\n",
+ "manager = await ServiceManagerWidget.with_auth_code(\n",
+ " client_id=client_id,\n",
+ " redirect_url=redirect_url,\n",
+ ").login()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "### Demo 1: Create a new attribute based on an existing attribute"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "Choose a block model from the dropdown. The widget lists block models in the workspace selected during sign-in."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from block_model_selector_widgets import BlockModelSelectorWidget\n",
+ "\n",
+ "from evo.blockmodels import BlockModelAPIClient\n",
+ "\n",
+ "environment = manager.get_environment()\n",
+ "\n",
+ "service_client = BlockModelAPIClient(environment, manager.get_connector(), manager.cache)\n",
+ "block_model_selector = await BlockModelSelectorWidget.create(service_client)\n",
+ "display(block_model_selector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "Choose an existing attribute and enter a unique name for the new attribute."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "6",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from block_model_selector_widgets import BlockModelAttributeSelectorWidget\n",
+ "\n",
+ "from evo.objects.data import ObjectReference\n",
+ "from evo.objects.typed import BlockModel\n",
+ "\n",
+ "api_block_model = await service_client.get_block_model(block_model_selector.value)\n",
+ "object_id = api_block_model.geoscience_object_id\n",
+ "if object_id is None:\n",
+ " raise RuntimeError(\"The selected block model is not linked to a geoscience object.\")\n",
+ "\n",
+ "object_reference = ObjectReference.new(environment, object_id=object_id)\n",
+ "block_model = await BlockModel.from_reference(manager, object_reference)\n",
+ "\n",
+ "print(f\"Selected block model: {block_model.name}\")\n",
+ "print(f\"Block model ID: {block_model.block_model_uuid}\")\n",
+ "attribute_selector = BlockModelAttributeSelectorWidget(block_model.attributes)\n",
+ "display(attribute_selector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "Create the new attribute by transforming the existing attribute. This example applies $new = old \\times scale + offset$. Keep the scale at `1.0` and the offset at `0.0` to make an exact copy.\n",
+ "\n",
+ "The notebook verifies that the selected attribute is numeric before it is transformed. Use a unit compatible with the transformed values, or leave `new_column_unit` as `None` when no unit is required."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "\n",
+ "source_attribute = attribute_selector.value\n",
+ "new_attribute = attribute_selector.new_attribute\n",
+ "scale = 2.0\n",
+ "offset = 0.0\n",
+ "new_column_unit = None # For example: \"g/t\"\n",
+ "\n",
+ "if not new_attribute:\n",
+ " raise ValueError(\"Enter a name for the transformed attribute.\")\n",
+ "if source_attribute in {\"x\", \"y\", \"z\"}:\n",
+ " raise ValueError(\"Choose a block model attribute, not a geometry column.\")\n",
+ "if block_model.get_attribute(source_attribute) is None:\n",
+ " raise ValueError(f\"{source_attribute!r} is not an attribute on the selected block model.\")\n",
+ "if block_model.get_attribute(new_attribute) is not None:\n",
+ " raise ValueError(f\"{new_attribute!r} already exists. Choose a new attribute name.\")\n",
+ "\n",
+ "source_data = await block_model.to_dataframe(columns=[\"x\", \"y\", \"z\", source_attribute])\n",
+ "if not pd.api.types.is_numeric_dtype(source_data[source_attribute]):\n",
+ " raise TypeError(f\"{source_attribute!r} must contain numeric values.\")\n",
+ "\n",
+ "comparison_data = source_data.copy()\n",
+ "comparison_data[new_attribute] = source_data[source_attribute] * scale + offset\n",
+ "new_attribute_data = comparison_data[[\"x\", \"y\", \"z\", new_attribute]]\n",
+ "\n",
+ "display(comparison_data.head())\n",
+ "print(f\"Transform: {new_attribute} = {source_attribute} * {scale} + {offset}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "Publish the new attribute. Evo creates a new block model version containing the new attribute."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "version = await block_model.add_attribute(\n",
+ " data=new_attribute_data,\n",
+ " attribute_name=new_attribute,\n",
+ " unit=new_column_unit,\n",
+ ")\n",
+ "\n",
+ "print(f\"Published {new_attribute!r} in version: {version.version_id}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "### Demo 2: Transform an existing attribute in place"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "12",
+ "metadata": {},
+ "source": [
+ "Choose a block model from the dropdown. The widget lists block models in the workspace selected during sign-in."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "13",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from block_model_selector_widgets import BlockModelSelectorWidget\n",
+ "\n",
+ "from evo.blockmodels import BlockModelAPIClient\n",
+ "\n",
+ "environment = manager.get_environment()\n",
+ "\n",
+ "service_client = BlockModelAPIClient(environment, manager.get_connector(), manager.cache)\n",
+ "\n",
+ "in_place_block_model_selector = await BlockModelSelectorWidget.create(service_client)\n",
+ "display(in_place_block_model_selector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "14",
+ "metadata": {},
+ "source": [
+ "Choose a numeric attribute to transform."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from block_model_selector_widgets import ExistingBlockModelAttributeSelectorWidget\n",
+ "\n",
+ "from evo.blockmodels.typed import Units\n",
+ "from evo.objects.data import ObjectReference\n",
+ "from evo.objects.typed import BlockModel\n",
+ "\n",
+ "api_block_model = await service_client.get_block_model(in_place_block_model_selector.value)\n",
+ "object_id = api_block_model.geoscience_object_id\n",
+ "if object_id is None:\n",
+ " raise RuntimeError(\"The selected block model is not linked to a geoscience object.\")\n",
+ "\n",
+ "object_reference = ObjectReference.new(environment, object_id=object_id)\n",
+ "in_place_block_model = await BlockModel.from_reference(manager, object_reference)\n",
+ "\n",
+ "print(f\"Selected block model: {in_place_block_model.name}\")\n",
+ "print(f\"Block model ID: {in_place_block_model.block_model_uuid}\")\n",
+ "in_place_attribute_selector = ExistingBlockModelAttributeSelectorWidget(in_place_block_model.attributes)\n",
+ "display(in_place_attribute_selector)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "16",
+ "metadata": {},
+ "source": [
+ "In the following code cell we transform the selected attribute from `grams` to `troy ounces` by applying the calculation $\\mathrm{troy\\ ounces} = \\mathrm{grams} / 31.1034768$. The preview shows the original gram and transformed troy-ounce values before the attribute is replaced.\n",
+ "\n",
+ "This example assumes the selected values are measured in grams, even though the source attribute may have no unit assigned. The conversion produces fractional values, so choose an attribute with a floating-point data type."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "17",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "\n",
+ "attribute_to_update = in_place_attribute_selector.value\n",
+ "grams_per_troy_ounce = 31.1034768\n",
+ "\n",
+ "if attribute_to_update in {\"x\", \"y\", \"z\"}:\n",
+ " raise ValueError(\"Choose a block model attribute, not a geometry column.\")\n",
+ "selected_attribute = in_place_block_model.get_attribute(attribute_to_update)\n",
+ "if selected_attribute is None:\n",
+ " raise ValueError(f\"{attribute_to_update!r} is not an attribute on the selected block model.\")\n",
+ "\n",
+ "source_data = await in_place_block_model.to_dataframe(columns=[\"x\", \"y\", \"z\", attribute_to_update])\n",
+ "source_values = source_data[attribute_to_update]\n",
+ "if not pd.api.types.is_float_dtype(source_values):\n",
+ " raise TypeError(f\"{attribute_to_update!r} must use a floating-point data type for the troy-ounce conversion.\")\n",
+ "\n",
+ "updated_values = source_values / grams_per_troy_ounce\n",
+ "updated_attribute_data = source_data.copy()\n",
+ "updated_attribute_data[attribute_to_update] = updated_values\n",
+ "preview_data = source_data.copy()\n",
+ "preview_data[f\"{attribute_to_update}_troy_ounces\"] = updated_values\n",
+ "\n",
+ "display(preview_data.head())\n",
+ "print(f\"Transform: {attribute_to_update} = {attribute_to_update} / {grams_per_troy_ounce}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "Publish the transformed values, then update the attribute unit to troy ounces. Evo creates a new block model version for each operation; the source version remains unchanged.\n",
+ "\n",
+ "The selected attribute's values are assumed to be grams, and it receives a troy-ounce unit after the conversion. Because the selected attribute is already floating point, its values can be updated without replacing the column."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "data_version = await in_place_block_model.update_attributes(\n",
+ " data=updated_attribute_data,\n",
+ " update_columns={attribute_to_update},\n",
+ ")\n",
+ "in_place_block_model = await in_place_block_model.set_attribute_units({attribute_to_update: Units.TROY_OUNCES})\n",
+ "\n",
+ "print(f\"Updated {attribute_to_update!r} in data version: {data_version.version_id}\")\n",
+ "print(f\"Set {attribute_to_update!r} unit to troy ounces ({Units.TROY_OUNCES}).\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "evo-code-samples (3.14.0)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.14.0"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/code-samples/blockmodels/reports.ipynb b/code-samples/blockmodels/sdk/03-block-model-reports.ipynb
similarity index 90%
rename from code-samples/blockmodels/reports.ipynb
rename to code-samples/blockmodels/sdk/03-block-model-reports.ipynb
index d641164b..0839270c 100644
--- a/code-samples/blockmodels/reports.ipynb
+++ b/code-samples/blockmodels/sdk/03-block-model-reports.ipynb
@@ -5,17 +5,21 @@
"id": "0",
"metadata": {},
"source": [
- "# Block Model Reports\n",
+ "# 03: Block Model Reports\n",
"\n",
- "This notebook demonstrates how to create and run resource estimation reports on block models.\n",
+ "This SDK tutorial demonstrates how to create and run resource-estimation reports on block models.\n",
"\n",
- "Reports provide tonnage, grade, and metal content summaries grouped by categories (e.g., geological domains).\n",
+ "Reports provide tonnage, grade, and metal-content summaries grouped by categories such as geological domains.\n",
+ "\n",
+ "## Before You Start\n",
+ "\n",
+ "Complete [01: Create and Query a Regular Block Model](01-create-and-query-regular-block-model.ipynb) first to become familiar with SDK authentication and block model concepts. This notebook creates its own report-ready sample model.\n",
"\n",
"## Requirements for Reports\n",
"\n",
- "1. **Units on columns** - Report columns must have units defined (e.g., `g/t` for grades)\n",
- "2. **At least one category column** - For grouping results (e.g., domain, rock type)\n",
- "3. **Density information** - Either a density column or a fixed density value"
+ "1. **Units on columns**: report columns must have units defined, for example `g/t` for grades.\n",
+ "2. **At least one category column**: used for grouping, such as domain or rock type.\n",
+ "3. **Density information**: either a density column or a fixed density value."
]
},
{
@@ -28,8 +32,8 @@
"from evo.notebooks import ServiceManagerWidget\n",
"\n",
"# Evo app credentials\n",
- "client_id = \"\" # Replace with your client ID\n",
- "redirect_url = \"\" # Replace with your redirect URL\n",
+ "client_id = \"your-client-id\" # Replace with your client ID\n",
+ "redirect_url = \"your-redirect-url\" # Replace with your redirect URL\n",
"\n",
"manager = await ServiceManagerWidget.with_auth_code(\n",
" client_id=client_id,\n",
@@ -52,7 +56,7 @@
"id": "3",
"metadata": {},
"source": [
- "## Create a Block Model with Sample Data\n",
+ "### Demo 1: Create a block model with sample data\n",
"\n",
"First, let's create a block model with some grade data that we can report on."
]
@@ -110,11 +114,11 @@
"metadata": {},
"outputs": [],
"source": [
- "import uuid\n",
+ "from datetime import datetime\n",
"\n",
"# Create the block model\n",
"bm_data = RegularBlockModelData(\n",
- " name=f\"Report Demo Block Model - {uuid.uuid4().hex[:8]}\",\n",
+ " name=f\"Report Demo Block Model {datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')}\",\n",
" description=\"Block model for demonstrating reports\",\n",
" origin=Point3(x=origin[0], y=origin[1], z=origin[2]),\n",
" n_blocks=Size3i(nx=n_blocks[0], ny=n_blocks[1], nz=n_blocks[2]),\n",
@@ -145,7 +149,7 @@
"id": "7",
"metadata": {},
"source": [
- "## Add a Domain Column\n",
+ "### Demo 2: Add a domain column\n",
"\n",
"Reports require at least one category column for grouping. Let's add a simple domain column by slicing the block model into three geological domains based on elevation (z-coordinate).\n",
"\n",
@@ -226,7 +230,7 @@
"id": "12",
"metadata": {},
"source": [
- "## Create a Report\n",
+ "### Demo 3: Create a report\n",
"\n",
"Now we can create a report specification. The report will calculate:\n",
"- Tonnage for each domain\n",
@@ -300,7 +304,7 @@
"id": "15",
"metadata": {},
"source": [
- "## View Report Results\n",
+ "### Demo 4: View report results\n",
"\n",
"Since we set `run_now=True`, the report was executed automatically. Let's get the results."
]
@@ -324,7 +328,7 @@
"id": "17",
"metadata": {},
"source": [
- "## Create a Report with Fixed Density\n",
+ "### Demo 5: Create a report with fixed density\n",
"\n",
"If you don't have a density column, you can use a fixed density value instead."
]
@@ -366,7 +370,7 @@
"id": "19",
"metadata": {},
"source": [
- "## Create a Report with Cut-offs\n",
+ "### Demo 6: Create a report with cut-offs\n",
"\n",
"Reports can also evaluate different cut-off grades. This is useful for grade-tonnage analysis."
]
@@ -421,7 +425,7 @@
"id": "22",
"metadata": {},
"source": [
- "## List All Reports\n",
+ "### Demo 7: List all reports\n",
"\n",
"You can list all report specifications on a block model."
]
diff --git a/code-samples/blockmodels/sdk/advanced/README.md b/code-samples/blockmodels/sdk/advanced/README.md
new file mode 100644
index 00000000..54b5b374
--- /dev/null
+++ b/code-samples/blockmodels/sdk/advanced/README.md
@@ -0,0 +1,12 @@
+# Advanced Block Model SDK Tutorials
+
+This directory is reserved for SDK workflows that require specialized grid definitions or larger data-management patterns. These topics should remain separate from the regular block model learning path.
+
+Planned topics:
+
+- Subblocked, fully subblocked, flexible, and octree grid definitions
+- Subblocked-column updates and `fill_subblocks`
+- Large-query export and cache-backed workflows
+- Version-delta and synchronization workflows
+
+Start with the [main Block Model SDK tutorials](../README.md) before working through these examples.
diff --git a/code-samples/blockmodels/sdk/block_model_selector_widgets.py b/code-samples/blockmodels/sdk/block_model_selector_widgets.py
new file mode 100644
index 00000000..91f81257
--- /dev/null
+++ b/code-samples/blockmodels/sdk/block_model_selector_widgets.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from uuid import UUID
+
+import ipywidgets as widgets
+
+from evo.blockmodels import BlockModelAPIClient
+
+
+class BlockModelSelectorWidget(widgets.VBox):
+ """Select an available block model and expose its UUID through ``value``."""
+
+ def __init__(self, block_models: list[object]) -> None:
+ options = [(f"{block_model.name} ({block_model.id})", block_model.id) for block_model in block_models]
+ self.selector = widgets.Dropdown(
+ description="Block model:",
+ options=options,
+ layout=widgets.Layout(width="auto"),
+ )
+ super().__init__([self.selector])
+
+ @property
+ def value(self) -> UUID:
+ """Return the UUID of the selected block model."""
+ return self.selector.value
+
+ @classmethod
+ async def create(cls, service_client: BlockModelAPIClient) -> BlockModelSelectorWidget:
+ """Load available block models from Evo and build a selector."""
+ block_models = await service_client.list_all_block_models()
+ if not block_models:
+ raise RuntimeError("No block models are available in the selected Evo workspace.")
+ return cls(sorted(block_models, key=lambda block_model: block_model.name.casefold()))
+
+
+class BlockModelAttributeSelectorWidget(widgets.VBox):
+ """Select a source attribute and enter a name for its transformed attribute."""
+
+ def __init__(self, attributes: list[object]) -> None:
+ attribute_names = sorted({attribute.name for attribute in attributes}, key=str.casefold)
+ if not attribute_names:
+ raise RuntimeError("The selected block model has no attributes to transform.")
+ self.selector = widgets.Dropdown(
+ description="Attribute:",
+ options=attribute_names,
+ layout=widgets.Layout(width="max-content", margin="0 0 6px 0"),
+ )
+ self.selector.style.description_width = "7em"
+ self.new_attribute_input = widgets.Text(
+ description="New attribute:",
+ placeholder="Enter a new attribute name",
+ value=f"{self.selector.value}_transformed",
+ layout=widgets.Layout(width="40ch"),
+ )
+ self.new_attribute_input.style.description_width = "7em"
+ self.selector.observe(self._update_new_attribute_name, names="value")
+ super().__init__(
+ [self.selector, self.new_attribute_input],
+ layout=widgets.Layout(padding="4px 0"),
+ )
+
+ def _update_new_attribute_name(self, change: dict[str, object]) -> None:
+ self.new_attribute_input.value = f"{change['new']}_transformed"
+
+ @property
+ def value(self) -> str:
+ """Return the name of the selected block model attribute."""
+ return self.selector.value
+
+ @property
+ def new_attribute(self) -> str:
+ """Return the requested name for the transformed attribute."""
+ return self.new_attribute_input.value.strip()
+
+
+class ExistingBlockModelAttributeSelectorWidget(widgets.VBox):
+ """Select an existing block model attribute to transform in place."""
+
+ def __init__(self, attributes: list[object]) -> None:
+ attribute_names = sorted({attribute.name for attribute in attributes}, key=str.casefold)
+ if not attribute_names:
+ raise RuntimeError("The selected block model has no attributes to transform.")
+ self.selector = widgets.Dropdown(
+ description="Attribute:",
+ options=attribute_names,
+ layout=widgets.Layout(width="max-content"),
+ )
+ self.selector.style.description_width = "7em"
+ super().__init__([self.selector], layout=widgets.Layout(padding="4px 0"))
+
+ @property
+ def value(self) -> str:
+ """Return the name of the selected block model attribute."""
+ return self.selector.value
diff --git a/code-samples/blockmodels/sdk/data/sample-data/data.parquet b/code-samples/blockmodels/sdk/data/sample-data/data.parquet
new file mode 100644
index 0000000000000000000000000000000000000000..6f50fafb63959f013d523f3453c348cd9ce184c4
GIT binary patch
literal 3899150
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