diff --git a/code-samples/blockmodels/reports.ipynb b/code-samples/blockmodels/reports.ipynb new file mode 100644 index 00000000..193c7c4f --- /dev/null +++ b/code-samples/blockmodels/reports.ipynb @@ -0,0 +1,478 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Block Model Reports\n", + "\n", + "This notebook 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", + "\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" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "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", + "\n", + "manager = await ServiceManagerWidget.with_auth_code(\n", + " redirect_url=redirect_url,\n", + " client_id=client_id,\n", + ").login()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext evo.widgets" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "## 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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from evo.blockmodels import Units\n", + "from evo.objects.typed import BlockModel, Point3, RegularBlockModelData, Size3d, Size3i\n", + "\n", + "# Define block model geometry\n", + "origin = (0, 0, 0)\n", + "n_blocks = (10, 10, 5) # 500 blocks total\n", + "block_size = (25.0, 25.0, 10.0) # metres\n", + "total_blocks = n_blocks[0] * n_blocks[1] * n_blocks[2]\n", + "\n", + "# Generate block centroid coordinates\n", + "np.random.seed(42)\n", + "centroids = []\n", + "for k in range(n_blocks[2]):\n", + " for j in range(n_blocks[1]):\n", + " for i in range(n_blocks[0]):\n", + " x = origin[0] + (i + 0.5) * block_size[0]\n", + " y = origin[1] + (j + 0.5) * block_size[1]\n", + " z = origin[2] + (k + 0.5) * block_size[2]\n", + " centroids.append((x, y, z))\n", + "\n", + "# Create sample data with grades and density\n", + "block_data = pd.DataFrame(\n", + " {\n", + " \"x\": [c[0] for c in centroids],\n", + " \"y\": [c[1] for c in centroids],\n", + " \"z\": [c[2] for c in centroids],\n", + " \"Au\": np.random.lognormal(mean=0.5, sigma=0.8, size=total_blocks), # Gold grade\n", + " \"density\": np.random.uniform(2.5, 2.9, size=total_blocks), # Bulk density\n", + " }\n", + ")\n", + "\n", + "print(f\"Created {len(block_data)} blocks\")\n", + "print(f\"Au grade range: {block_data['Au'].min():.2f} - {block_data['Au'].max():.2f}\")\n", + "block_data.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "import uuid\n", + "\n", + "# Create the block model\n", + "bm_data = RegularBlockModelData(\n", + " name=f\"Report Demo Block Model - {uuid.uuid4().hex[:8]}\",\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", + " block_size=Size3d(dx=block_size[0], dy=block_size[1], dz=block_size[2]),\n", + " cell_data=block_data,\n", + " crs=\"EPSG:28354\",\n", + " size_unit_id=Units.METRES,\n", + " units={\"Au\": Units.GRAMS_PER_TONNE, \"density\": Units.TONNES_PER_CUBIC_METRE},\n", + ")\n", + "\n", + "block_model = await BlockModel.create_regular(manager, bm_data)\n", + "print(f\"Created block model: {block_model.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "# Pretty-print the block model (shows Portal/Viewer links)\n", + "block_model" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "## 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", + "In practice, domains would come from geological interpretation, but this demonstrates the concept." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the current block data\n", + "df = await block_model.to_dataframe()\n", + "\n", + "# Create domain column based on z-coordinate (elevation)\n", + "# Divide into 3 domains: LMS1 (lower), LMS2 (middle), LMS3 (upper)\n", + "z_min, z_max = df[\"z\"].min(), df[\"z\"].max()\n", + "z_range = z_max - z_min\n", + "\n", + "\n", + "def assign_domain(z):\n", + " if z < z_min + z_range / 3:\n", + " return \"LMS1\" # Lower domain\n", + " elif z < z_min + 2 * z_range / 3:\n", + " return \"LMS2\" # Middle domain\n", + " else:\n", + " return \"LMS3\" # Upper domain\n", + "\n", + "\n", + "df[\"domain\"] = df[\"z\"].apply(assign_domain)\n", + "\n", + "# Check domain distribution\n", + "print(\"Domain distribution:\")\n", + "print(df[\"domain\"].value_counts())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "# Add the domain column to the block model\n", + "# Include geometry columns (x, y, z) for block identification\n", + "domain_data = df[[\"x\", \"y\", \"z\", \"domain\"]]\n", + "\n", + "version = await block_model.add_attribute(domain_data, \"domain\")\n", + "print(f\"Added domain column. New version: {version.version_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "version" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "# Refresh to see the new attribute\n", + "block_model = await block_model.refresh()\n", + "block_model.attributes" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "## Create a Report\n", + "\n", + "Now we can create a report specification. The report will calculate:\n", + "- Tonnage for each domain\n", + "- Average Au grade (weighted by mass)\n", + "- Total Au metal content\n", + "\n", + "**Key classes for reports:**\n", + "- `Aggregation` - Enum: `MASS_AVERAGE` (for grades), `SUM` (for metal content)\n", + "- `Units` - Constants for output units (e.g., `Units.GRAMS_PER_TONNE`)\n", + "- `MassUnits` - Constants for mass output (e.g., `MassUnits.TONNES`)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.blockmodels import Units\n", + "from evo.blockmodels.typed import (\n", + " Aggregation,\n", + " MassUnits,\n", + " ReportCategorySpec,\n", + " ReportColumnSpec,\n", + " ReportSpecificationData,\n", + ")\n", + "\n", + "# Define the report\n", + "report_data = ReportSpecificationData(\n", + " name=\"Gold Resource Report\",\n", + " description=\"Resource estimate by domain for Au\",\n", + " columns=[\n", + " ReportColumnSpec(\n", + " column_name=\"Au\",\n", + " aggregation=Aggregation.MASS_AVERAGE, # Use MASS_AVERAGE for grades\n", + " label=\"Au Grade\",\n", + " output_unit_id=Units.GRAMS_PER_TONNE, # Use Units class for discoverability\n", + " ),\n", + " ],\n", + " categories=[\n", + " ReportCategorySpec(\n", + " column_name=\"domain\",\n", + " label=\"Domain\",\n", + " values=[\"LMS1\", \"LMS2\", \"LMS3\"], # Optional: specify values to include\n", + " ),\n", + " ],\n", + " mass_unit_id=MassUnits.TONNES, # Use MassUnits class\n", + " density_column_name=\"density\", # Use density column for mass calculation\n", + " run_now=True, # Run immediately after creation\n", + ")\n", + "\n", + "# Create the report\n", + "report = await block_model.create_report(report_data)\n", + "print(f\"Created report: {report.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "# Pretty-print the report (shows BlockSync link)\n", + "report" + ] + }, + { + "cell_type": "markdown", + "id": "15", + "metadata": {}, + "source": [ + "## View Report Results\n", + "\n", + "Since we set `run_now=True`, the report was executed automatically. Let's get the results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the latest report result (waits if report is still running)\n", + "result = await report.refresh()\n", + "\n", + "# Pretty-print the result (displays table in Jupyter)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "## Create a Report with Fixed Density\n", + "\n", + "If you don't have a density column, you can use a fixed density value instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a report with fixed density\n", + "fixed_density_report_data = ReportSpecificationData(\n", + " name=\"Au Report (Fixed Density)\",\n", + " description=\"Resource estimate using fixed density of 2.7 t/m3\",\n", + " columns=[\n", + " ReportColumnSpec(\n", + " column_name=\"Au\",\n", + " aggregation=Aggregation.MASS_AVERAGE,\n", + " label=\"Au Grade\",\n", + " output_unit_id=Units.GRAMS_PER_TONNE,\n", + " ),\n", + " ],\n", + " categories=[\n", + " ReportCategorySpec(column_name=\"domain\", label=\"Domain\"),\n", + " ],\n", + " mass_unit_id=MassUnits.TONNES,\n", + " density_value=2.7, # Fixed density value\n", + " density_unit_id=Units.TONNES_PER_CUBIC_METRE, # Unit for fixed density\n", + " run_now=True,\n", + ")\n", + "\n", + "fixed_report = await block_model.create_report(fixed_density_report_data)\n", + "print(f\"Created report: {fixed_report.name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "19", + "metadata": {}, + "source": [ + "## Create a Report with Cut-offs\n", + "\n", + "Reports can also evaluate different cut-off grades. This is useful for grade-tonnage analysis." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a report with cut-off values\n", + "cutoff_report_data = ReportSpecificationData(\n", + " name=\"Au Grade-Tonnage Report\",\n", + " description=\"Grade-tonnage analysis with Au cut-offs\",\n", + " columns=[\n", + " ReportColumnSpec(\n", + " column_name=\"Au\",\n", + " aggregation=Aggregation.MASS_AVERAGE,\n", + " label=\"Au Grade\",\n", + " output_unit_id=Units.GRAMS_PER_TONNE,\n", + " ),\n", + " ],\n", + " categories=[\n", + " ReportCategorySpec(column_name=\"domain\", label=\"Domain\"),\n", + " ],\n", + " mass_unit_id=MassUnits.TONNES,\n", + " density_column_name=\"density\",\n", + " cutoff_column_name=\"Au\", # Apply cut-off on Au grade\n", + " cutoff_values=[0.5, 1.0, 2.0, 3.0], # Cut-off values in g/t\n", + " run_now=True,\n", + ")\n", + "\n", + "cutoff_report = await block_model.create_report(cutoff_report_data)\n", + "print(f\"Created report: {cutoff_report.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "# Get and display results\n", + "cutoff_result = await cutoff_report.refresh()\n", + "cutoff_result" + ] + }, + { + "cell_type": "markdown", + "id": "22", + "metadata": {}, + "source": [ + "## List All Reports\n", + "\n", + "You can list all report specifications on a block model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "# List all reports on this block model\n", + "reports = await block_model.list_reports()\n", + "\n", + "print(f\"Found {len(reports)} report(s):\")\n", + "for r in reports:\n", + " print(f\" - {r.name} (revision {r.revision})\")" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "This notebook demonstrated:\n", + "\n", + "1. **Creating a block model** with grade and density data\n", + "2. **Adding a domain column** for grouping (simulating geological domains)\n", + "3. **Creating reports** with the typed `Report` API\n", + "4. **Viewing results** as DataFrames\n", + "5. **Using fixed density** vs density column\n", + "6. **Grade-tonnage analysis** with cut-off values\n", + "7. **Pretty printing** reports with BlockSync links\n", + "\n", + "The `report` object provides a `blocksync_url` property that links directly to the report in BlockSync for interactive viewing and analysis." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/code-samples/geoscience-objects/running-kriging-compute/running-kriging-compute.ipynb b/code-samples/geoscience-objects/running-kriging-compute/running-kriging-compute.ipynb index 1ea9e9aa..443e9681 100644 --- a/code-samples/geoscience-objects/running-kriging-compute/running-kriging-compute.ipynb +++ b/code-samples/geoscience-objects/running-kriging-compute/running-kriging-compute.ipynb @@ -539,20 +539,33 @@ "source": [ "---\n", "\n", - "## WIP: Creating Kriging and Compute\n", + "## 8. Create Target Block Model\n", "\n", - "The following sections demonstrate how to run kriging estimation using Evo Compute.\n", - "\n", - "**Note:** This functionality is under development. The code below shows the expected API pattern." + "Create a Block Model to hold the kriging results. The block model defines the estimation grid." ] }, { - "cell_type": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "### WIP: Create Target Block Model\n", + "from evo.blockmodels import Point3, RegularBlockModel, RegularBlockModelData, Size3d, Size3i, Units\n", + "\n", + "# Define block model covering the drill hole extent\n", + "bm_data = RegularBlockModelData(\n", + " name=\"CU Kriging Estimate\",\n", + " description=\"Block model with kriged copper grades\",\n", + " origin=Point3(x=444750, y=492850, z=2350),\n", + " n_blocks=Size3i(nx=50, ny=75, nz=45), # 50x75x45 blocks\n", + " block_size=Size3d(dx=20.0, dy=20.0, dz=20.0), # 20m blocks\n", + " coordinate_reference_system=\"EPSG:32650\",\n", + " size_unit_id=Units.METRES,\n", + ")\n", "\n", - "Create a Block Model to hold the kriging results. The block model defines the estimation grid." + "block_model = await RegularBlockModel.create(manager, bm_data)\n", + "print(f\"Created Block Model: {block_model.name}\")\n", + "print(f\"Block Model ID: {block_model.id}\")" ] }, { @@ -561,32 +574,15 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: Create target block model for kriging estimation\n", - "#\n", - "# from evo.objects.typed import BlockModel, RegularBlockModelData, Point3, Size3i, Size3d\n", - "# from evo.blockmodels import Units\n", - "#\n", - "# # Define block model covering the downhole extent\n", - "# bm_data = RegularBlockModelData(\n", - "# name=\"CU Kriging Estimate\",\n", - "# description=\"Block model with kriged copper grades\",\n", - "# origin=Point3(x=444750, y=492850, z=2350),\n", - "# n_blocks=Size3i(nx=50, ny=75, nz=45), # 50x75x45 blocks\n", - "# block_size=Size3d(dx=20.0, dy=20.0, dz=20.0), # 20m blocks\n", - "# crs=\"EPSG:32650\",\n", - "# size_unit_id=Units.METRES,\n", - "# )\n", - "#\n", - "# block_model = await BlockModel.create_regular(manager, bm_data)\n", - "# print(f\"Created Block Model: {block_model.name}\")\n", - "# print(f\"Bounding Box: {block_model.bounding_box}\")" + "# Display the block model metadata\n", + "block_model.version" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### WIP: Define Kriging Parameters\n", + "## WIP. Define Kriging Parameters\n", "\n", "Configure the kriging search neighborhood and estimation parameters." ] @@ -597,15 +593,13 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: Define kriging parameters\n", - "#\n", "# from evo.compute.tasks import SearchNeighborhood\n", "# from evo.compute.tasks.kriging import KrigingParameters\n", "#\n", "# # Use the search ellipsoid we created earlier (2x variogram range)\n", "# params = KrigingParameters(\n", "# source=pointset.attributes[\"CU_pct\"], # Source attribute\n", - "# target=block_model.attributes[\"CU_estimate\"], # Target attribute\n", + "# target=block_model.attributes[f\"CU_samples_{max_samples}\"]\n", "# variogram=variogram,\n", "# search=SearchNeighborhood(\n", "# ellipsoid=search_ellipsoid,\n", @@ -614,7 +608,7 @@ "# ),\n", "# )\n", "#\n", - "# print(f\"Kriging source: {params.source}\")\n", + "# print(f\"Kriging source: CU_pct from pointset\")\n", "# print(f\"Search ellipsoid: major={search_ellipsoid.ranges.major}m\")" ] }, @@ -622,7 +616,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### WIP: Run Kriging Task\n", + "## WIP. Run Kriging Task\n", "\n", "Submit and run the kriging task using Evo Compute." ] @@ -633,8 +627,6 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: Run kriging task\n", - "#\n", "# from evo.compute.tasks import run\n", "#\n", "# # Submit kriging task (progress feedback is shown by default)\n", @@ -642,14 +634,14 @@ "# results = await run(manager, [params])\n", "#\n", "# print(f\"Kriging complete!\")\n", - "# print(f\"Result: {results[0].message}\")" + "# print(f\"Result: {results[0].status}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### WIP: View Kriging Results\n", + "## WIP. View Kriging Results\n", "\n", "Refresh the block model and view the estimated grades." ] @@ -660,13 +652,11 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: View kriging results\n", - "#\n", - "# # Refresh block model to see new attributes\n", - "# block_model = await block_model.refresh()\n", - "#\n", - "# # Display the block model (pretty-printed with Portal/Viewer links)\n", - "# block_model" + "# Refresh block model to see new attributes\n", + "await block_model.refresh()\n", + "\n", + "# Display the block model version (shows updated columns)\n", + "block_model.version" ] }, { @@ -675,21 +665,19 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: Query estimated values\n", - "#\n", - "# # Get the kriged values as a DataFrame\n", - "# results_df = await block_model.to_dataframe(columns=[\"CU_estimate\"])\n", - "#\n", - "# print(f\"Estimated {len(results_df)} blocks\")\n", - "# print(f\"\\nStatistics:\")\n", - "# print(results_df[\"CU_estimate\"].describe())" + "# Get the kriged values as a DataFrame\n", + "results_df = block_model.cell_data\n", + "\n", + "print(f\"Estimated {len(results_df)} blocks\")\n", + "print(\"\\nStatistics for CU_estimate:\")\n", + "print(results_df[\"CU_estimate\"].describe())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### WIP: Running Multiple Kriging Scenarios\n", + "## WIP. Running Multiple Kriging Scenarios\n", "\n", "Run multiple kriging tasks concurrently to compare different parameters." ] @@ -700,8 +688,6 @@ "metadata": {}, "outputs": [], "source": [ - "# WIP: Multiple kriging scenarios with different max_samples\n", - "#\n", "# max_samples_values = [5, 10, 15, 20]\n", "#\n", "# # Create parameter sets for each scenario\n", @@ -721,7 +707,7 @@ "# # Run all scenarios in parallel\n", "# print(f\"Submitting {len(parameter_sets)} kriging tasks...\")\n", "# results = await run(manager, parameter_sets)\n", - "# print(f\"All {len(results)} scenarios completed!\")" + "# print(f\"All {len(results)} scenarios completed!\")\n" ] } ], diff --git a/packages/evo-blockmodels/pyproject.toml b/packages/evo-blockmodels/pyproject.toml index 78ba346c..931ff329 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.1.1" +version = "0.2.0" requires-python = ">=3.10" license-files = ["LICENSE.md"] dynamic = ["readme"] @@ -21,7 +21,7 @@ Homepage = "https://www.seequent.com/" Documentation = "https://developer.seequent.com/" [project.optional-dependencies] -pyarrow = ["pyarrow>=19.0.0"] +pyarrow = ["pyarrow>=19.0.0", "pandas>=2.0.0"] aiohttp = ["evo-sdk-common[aiohttp]>=0.1.0"] notebooks = ["evo-sdk-common[notebooks]>=0.1.0"] diff --git a/packages/evo-blockmodels/src/evo/blockmodels/__init__.py b/packages/evo-blockmodels/src/evo/blockmodels/__init__.py index d0d4d103..e1e67faf 100644 --- a/packages/evo-blockmodels/src/evo/blockmodels/__init__.py +++ b/packages/evo-blockmodels/src/evo/blockmodels/__init__.py @@ -10,7 +10,27 @@ # limitations under the License. from .client import BlockModelAPIClient +from .typed import ( + BaseTypedBlockModel, + BoundingBox, + Point3, + RegularBlockModel, + RegularBlockModelData, + Size3d, + Size3i, + Units, + get_available_units, +) __all__ = [ + "BaseTypedBlockModel", "BlockModelAPIClient", + "BoundingBox", + "Point3", + "RegularBlockModel", + "RegularBlockModelData", + "Size3d", + "Size3i", + "Units", + "get_available_units", ] diff --git a/packages/evo-blockmodels/src/evo/blockmodels/client.py b/packages/evo-blockmodels/src/evo/blockmodels/client.py index 8abeeba5..3a60397a 100644 --- a/packages/evo-blockmodels/src/evo/blockmodels/client.py +++ b/packages/evo-blockmodels/src/evo/blockmodels/client.py @@ -33,7 +33,7 @@ Version, ) from .endpoints import models -from .endpoints.api import ColumnOperationsApi, JobsApi, MetadataApi, OperationsApi, VersionsApi +from .endpoints.api import ColumnOperationsApi, JobsApi, MetadataApi, OperationsApi, ReportsApi, VersionsApi from .endpoints.models import ( AnyUrl, BBox, @@ -127,6 +127,7 @@ def __init__(self, environment: Environment, connector: APIConnector, cache: ICa self._operations_api = OperationsApi(connector) self._column_operations_api = ColumnOperationsApi(connector) self._metadata_api = MetadataApi(connector) + self._reports_api = ReportsApi(connector) self._cache = cache @classmethod @@ -311,7 +312,6 @@ async def _upload_data(self, bm_id: uuid.UUID, job_id: uuid.UUID, upload_url: st cache_location = get_cache_location_for_upload(self._cache, self._environment, job_id) pyarrow.parquet.write_table(data, cache_location) - # Upload the data upload = BlockModelUpload(self._connector, self._environment, bm_id, job_id, upload_url) await upload.upload_from_path(cache_location, self._connector.transport) @@ -368,6 +368,19 @@ async def list_block_models(self) -> list[BlockModel]: return [self._bm_from_model(m) for m in response.results] + async def get_block_model(self, bm_id: UUID) -> BlockModel: + """Get a block model by ID. + + :param bm_id: The ID of the block model to retrieve. + :return: The BlockModel metadata. + """ + response = await self._metadata_api.retrieve_block_model( + bm_id=str(bm_id), + workspace_id=str(self._environment.workspace_id), + org_id=str(self._environment.org_id), + ) + return self._bm_from_model(response) + async def list_all_block_models(self, page_limit: int | None = 100) -> list[BlockModel]: """Return all block models for the current workspace, following paginated responses. @@ -527,6 +540,26 @@ async def create_block_model( version = await self._add_new_columns(create_result.bm_uuid, initial_data, units, geometry_change) return self._bm_from_model(create_result), version + async def add_new_subblocked_columns( + self, + bm_id: UUID, + data: Table, + units: dict[str, str] | None = None, + ): + """Add new columns to an existing sub-blocked block model. This will not change the sub-blocking structure, thus the provided data must match existing sub-blocks in the model. + + Units for the columns can be provided in the `units` dictionary. + + This method requires the `pyarrow` package to be installed, and the 'cache' parameter to be set in the constructor. + + :param bm_id: The ID of the block model to add columns to. + :param data: The data containing the new columns to add. + :param units: A dictionary mapping column names within `data` to units. + :raises CacheNotConfiguredException: If the cache is not configured. + :return: The new version of the block model with the added columns. + """ + return await self._add_new_columns(bm_id, data, units, geometry_change=False) + async def _add_new_columns( self, bm_id: UUID, @@ -580,26 +613,6 @@ async def _add_new_columns( version = await self._upload_data(bm_id, update_response.job_uuid, str(update_response.upload_url), data) return _version_from_model(version) - async def add_new_subblocked_columns( - self, - bm_id: UUID, - data: Table, - units: dict[str, str] | None = None, - ): - """Add new columns to an existing sub-blocked block model. This will not change the sub-blocking structure, thus the provided data must match existing sub-blocks in the model. - - Units for the columns can be provided in the `units` dictionary. - - This method requires the `pyarrow` package to be installed, and the 'cache' parameter to be set in the constructor. - - :param bm_id: The ID of the block model to add columns to. - :param data: The data containing the new columns to add. - :param units: A dictionary mapping column names within `data` to units. - :raises CacheNotConfiguredException: If the cache is not configured. - :return: The new version of the block model with the added columns. - """ - return await self._add_new_columns(bm_id, data, units, geometry_change=False) - async def add_new_columns( self, bm_id: UUID, diff --git a/packages/evo-blockmodels/src/evo/blockmodels/data.py b/packages/evo-blockmodels/src/evo/blockmodels/data.py index bf757f58..3abd55f8 100644 --- a/packages/evo-blockmodels/src/evo/blockmodels/data.py +++ b/packages/evo-blockmodels/src/evo/blockmodels/data.py @@ -230,3 +230,16 @@ class Version: """ Columns within this version """ + + def __repr__(self) -> str: + """Return a concise string representation of the version.""" + col_names = [c.title for c in self.columns] + bbox_str = "" + if self.bbox: + bbox_str = f", bbox=i[{self.bbox.i_minmax.min}-{self.bbox.i_minmax.max}] j[{self.bbox.j_minmax.min}-{self.bbox.j_minmax.max}] k[{self.bbox.k_minmax.min}-{self.bbox.k_minmax.max}]" + return ( + f"Version(id={self.version_id}, " + f"created={self.created_at.strftime('%Y-%m-%d %H:%M:%S')}, " + f"by={self.created_by.name or self.created_by.email}{bbox_str}, " + f"columns={col_names})" + ) diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py new file mode 100644 index 00000000..c0bf284a --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/__init__.py @@ -0,0 +1,47 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed access for block models with pandas DataFrame support.""" + +from .base import BaseTypedBlockModel +from .regular_block_model import RegularBlockModel, RegularBlockModelData +from .report import ( + Aggregation, + MassUnits, + Report, + ReportCategorySpec, + ReportColumnSpec, + ReportResult, + ReportSpecificationData, +) +from .types import BoundingBox, Point3, Size3d, Size3i +from .units import UnitInfo, Units, UnitType, get_available_units + +__all__ = [ + "Aggregation", + "BaseTypedBlockModel", + "BoundingBox", + "MassUnits", + "Point3", + "RegularBlockModel", + "RegularBlockModelData", + "Report", + "ReportCategorySpec", + "ReportColumnSpec", + "ReportResult", + "ReportSpecificationData", + "Size3d", + "Size3i", + "UnitInfo", + "UnitType", + "Units", + "get_available_units", +] diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/_utils.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/_utils.py new file mode 100644 index 00000000..4560196b --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/_utils.py @@ -0,0 +1,110 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility functions for typed block model access.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import pandas as pd + import pyarrow as pa + +__all__ = [ + "dataframe_to_pyarrow", + "pyarrow_to_dataframe", +] + +# Geometry columns for regular block models +GEOMETRY_COLUMNS_IJK = {"i", "j", "k"} +GEOMETRY_COLUMNS_XYZ = {"x", "y", "z"} +GEOMETRY_COLUMNS = GEOMETRY_COLUMNS_IJK | GEOMETRY_COLUMNS_XYZ + + +def _check_pyarrow_available() -> None: + """Check if pyarrow is available, raise ImportError with helpful message if not.""" + try: + import pyarrow # noqa: F401 + except ImportError as e: + raise ImportError( + "pyarrow is required for this operation. Install it with: pip install evo-blockmodels[pyarrow]" + ) from e + + +def _check_pandas_available() -> None: + """Check if pandas is available, raise ImportError with helpful message if not.""" + try: + import pandas # noqa: F401 + except ImportError as e: + raise ImportError( + "pandas is required for this operation. Install it with: pip install evo-blockmodels[pyarrow]" + ) from e + + +def pyarrow_to_dataframe(table: "pa.Table") -> "pd.DataFrame": + """Convert a PyArrow Table to a pandas DataFrame. + + :param table: The PyArrow Table to convert. + :return: A pandas DataFrame. + :raises ImportError: If pyarrow or pandas is not installed. + """ + _check_pyarrow_available() + _check_pandas_available() + return table.to_pandas() + + +def dataframe_to_pyarrow(df: "pd.DataFrame") -> "pa.Table": + """Convert a pandas DataFrame to a PyArrow Table. + + Ensures geometry columns (i, j, k) are present and properly typed. + The i, j, k columns are cast to uint32 as required by the Block Model Service. + + :param df: The pandas DataFrame to convert. + :return: A PyArrow Table. + :raises ValueError: If required geometry columns are missing. + :raises ImportError: If pyarrow or pandas is not installed. + """ + _check_pyarrow_available() + _check_pandas_available() + + import pyarrow as pa + + # Check for required geometry columns + columns = set(df.columns) + has_ijk = GEOMETRY_COLUMNS_IJK.issubset(columns) + has_xyz = GEOMETRY_COLUMNS_XYZ.issubset(columns) + + if not has_ijk and not has_xyz: + raise ValueError("DataFrame must contain either (i, j, k) or (x, y, z) geometry columns") + + # Convert to PyArrow table + table = pa.Table.from_pandas(df) + + # Cast i, j, k columns to uint32 as required by the Block Model Service + if has_ijk: + for col_name in GEOMETRY_COLUMNS_IJK: + col_idx = table.schema.get_field_index(col_name) + if col_idx >= 0: + col = table.column(col_name) + if col.type != pa.uint32(): + table = table.set_column(col_idx, col_name, col.cast(pa.uint32())) + + return table + + +def get_attribute_columns(df: Any) -> list[str]: + """Get the list of attribute (non-geometry) columns from a DataFrame. + + :param df: The DataFrame to extract attribute columns from. + :return: A list of attribute column names. + """ + return [col for col in df.columns if col not in GEOMETRY_COLUMNS] diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/base.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/base.py new file mode 100644 index 00000000..4b162392 --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/base.py @@ -0,0 +1,410 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Base class for typed block model access. + +Provides shared functionality for all block model types (regular, sub-blocked, octree, etc.) +including data access, attribute management, reports, and versioning. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Literal +from uuid import UUID + +import pandas as pd + +from evo.common import IContext, IFeedback +from evo.common.utils import NoFeedback + +from ..client import BlockModelAPIClient +from ..data import BlockModel, Version +from ..endpoints.models import ColumnHeaderType +from ._utils import dataframe_to_pyarrow, get_attribute_columns, pyarrow_to_dataframe +from .report import Report, ReportSpecificationData +from .types import Point3 + +__all__ = [ + "BaseTypedBlockModel", +] + + +class BaseTypedBlockModel(ABC): + """Abstract base class for typed block model wrappers. + + Provides shared functionality for all block model types including: + - Data access via pandas DataFrames + - Attribute management (add, update, delete, set units) + - Report creation and listing + - Version management + - Metadata access + + Subclasses must implement grid-type-specific properties and factory methods. + """ + + def __init__( + self, + client: BlockModelAPIClient, + metadata: BlockModel, + version: Version, + cell_data: pd.DataFrame, + ) -> None: + """Initialize a BaseTypedBlockModel instance. + + :param client: The BlockModelAPIClient used for API operations. + :param metadata: The block model metadata. + :param version: The current version information. + :param cell_data: The cell data as a pandas DataFrame. + """ + self._client = client + self._metadata = metadata + self._version = version + self._cell_data = cell_data + + @property + def id(self) -> UUID: + """The unique identifier of the block model.""" + return self._metadata.id + + @property + def name(self) -> str: + """The name of the block model.""" + return self._metadata.name + + @property + def description(self) -> str | None: + """The description of the block model.""" + return self._metadata.description + + @property + def origin(self) -> Point3: + """The origin point of the block model grid.""" + grid_def = self._metadata.grid_definition + return Point3( + x=grid_def.model_origin[0], + y=grid_def.model_origin[1], + z=grid_def.model_origin[2], + ) + + @property + def metadata(self) -> BlockModel: + """The full block model metadata.""" + return self._metadata + + @property + def version(self) -> Version: + """The current version information.""" + return self._version + + @property + def cell_data(self) -> pd.DataFrame: + """The cell data as a pandas DataFrame.""" + return self._cell_data + + # ---- Data access ---- + + async def to_dataframe( + self, + columns: list[str] | None = None, + version_uuid: UUID | None | Literal["latest"] = "latest", + fb: IFeedback = NoFeedback, + ) -> pd.DataFrame: + """Get block model data as a DataFrame. + + Retrieves data from the Block Model Service and returns it as a pandas DataFrame + with user-friendly column names. + + :param columns: List of column names to retrieve. Defaults to all columns ["*"]. + :param version_uuid: Specific version to query. Use "latest" (default) for the latest version, + or None to use the version referenced by this object. + :param fb: Optional feedback interface for progress reporting. + :return: DataFrame containing the block model data. + + Example: + >>> df = await block_model.to_dataframe() + >>> df.head() + """ + fb.progress(0.0, "Querying block model data...") + + # Determine which version to query + query_version: UUID | None = None + if version_uuid == "latest": + query_version = None + elif version_uuid is None: + query_version = self._version.version_uuid + else: + query_version = version_uuid + + if columns is None: + columns = ["*"] + + table = await self._client.query_block_model_as_table( + bm_id=self._metadata.id, + columns=columns, + version_uuid=query_version, + column_headers=ColumnHeaderType.name, + ) + + fb.progress(0.9, "Converting data...") + result = table.to_pandas() + fb.progress(1.0, "Data retrieved") + return result + + # ---- Attribute management ---- + + async def add_attribute( + self, + data: pd.DataFrame, + attribute_name: str, + unit: str | None = None, + fb: IFeedback = NoFeedback, + ) -> Version: + """Add a new attribute to the block model. + + The DataFrame must contain geometry columns (i, j, k) or (x, y, z) and the + attribute column to add. + + :param data: DataFrame containing geometry columns and the new attribute. + :param attribute_name: Name of the attribute column in the DataFrame to add. + :param unit: Optional unit ID for the attribute (must be a valid unit ID from the Block Model Service). + :param fb: Optional feedback interface for progress reporting. + :return: The new version created by adding the attribute. + """ + fb.progress(0.0, "Preparing attribute data...") + + table = dataframe_to_pyarrow(data) + + fb.progress(0.2, "Uploading attribute...") + + units = {attribute_name: unit} if unit else None + version = await self._client.add_new_columns( + bm_id=self._metadata.id, + data=table, + units=units, + ) + + fb.progress(1.0, "Attribute added") + return version + + async def update_attributes( + self, + data: pd.DataFrame, + new_columns: list[str] | None = None, + update_columns: set[str] | None = None, + delete_columns: set[str] | None = None, + units: dict[str, str] | None = None, + fb: IFeedback = NoFeedback, + ) -> Version: + """Update attributes in the block model. + + :param data: DataFrame containing the updated data with geometry columns. + :param new_columns: List of new column names to add. + :param update_columns: Set of existing column names to update. + :param delete_columns: Set of column names to delete. + :param units: Optional dictionary mapping column names to unit identifiers. + :param fb: Optional feedback interface for progress reporting. + :return: The new version created by the update. + """ + fb.progress(0.0, "Preparing attribute update...") + + table = dataframe_to_pyarrow(data) + + fb.progress(0.2, "Uploading updated data...") + + if new_columns is None and update_columns is None: + new_columns = get_attribute_columns(data) + + version = await self._client.update_block_model_columns( + bm_id=self._metadata.id, + data=table, + new_columns=new_columns or [], + update_columns=update_columns, + delete_columns=delete_columns, + units=units, + ) + + fb.progress(0.4, "Data uploaded, processing...") + + self._version = version + self._cell_data = data.copy() + + fb.progress(1.0, "Attributes updated successfully") + return version + + async def set_attribute_units( + self, + units: dict[str, str], + fb: IFeedback = NoFeedback, + ) -> Version: + """Set units for attributes on this block model. + + This is required before creating reports, as reports need columns to have + units defined. + + :param units: Dictionary mapping attribute names to unit IDs (e.g., {"Au": "g/t", "density": "t/m3"}). + :param fb: Optional feedback interface for progress reporting. + :return: The new version created by the metadata update. + + Example: + >>> from evo.blockmodels import Units + >>> version = await block_model.set_attribute_units({ + ... "Au": Units.GRAMS_PER_TONNE, + ... "density": Units.TONNES_PER_CUBIC_METRE, + ... }) + """ + fb.progress(0.0, "Updating attribute units...") + + version = await self._client.update_column_metadata( + bm_id=self._metadata.id, + column_updates=units, + ) + + fb.progress(0.9, "Refreshing metadata...") + + self._version = version + + fb.progress(1.0, "Units updated") + return version + + # ---- Version management ---- + + async def get_versions(self) -> list[Version]: + """Get all versions of this block model. + + :return: List of versions, ordered from newest to oldest. + """ + return await self._client.list_versions(self._metadata.id) + + async def get_block_model_metadata(self) -> BlockModel: + """Get the full block model metadata from the Block Model Service. + + :return: The BlockModel metadata from the Block Model Service. + """ + return await self._client.get_block_model(self._metadata.id) + + # ---- Reports ---- + + def _get_column_id_map(self) -> dict[str, UUID]: + """Get a mapping of column names to their UUIDs from the current version. + + :return: Dictionary mapping column names to UUIDs. + """ + result = {} + if self._version and self._version.columns: + for col in self._version.columns: + if col.col_id: + try: + result[col.title] = UUID(col.col_id) + except ValueError: + pass + return result + + async def create_report( + self, + data: ReportSpecificationData, + fb: IFeedback = NoFeedback, + ) -> Report: + """Create a new report specification for this block model. + + Reports require: + 1. Columns to have units set (use `set_attribute_units()` first) + 2. At least one category column for grouping (e.g., domain, rock type) + + :param data: The report specification data. + :param fb: Optional feedback interface for progress reporting. + :return: A Report instance representing the created report. + + Example: + >>> from evo.blockmodels.typed import ReportSpecificationData, ReportColumnSpec, ReportCategorySpec + >>> report = await block_model.create_report(ReportSpecificationData( + ... name="Gold Resource Report", + ... columns=[ReportColumnSpec(column_name="Au", aggregation="WEIGHTED_MEAN", output_unit_id="g/t")], + ... categories=[ReportCategorySpec(column_name="domain")], + ... mass_unit_id="t", + ... density_value=2.7, + ... density_unit_id="t/m3", + ... )) + """ + fb.progress(0.0, "Preparing report specification...") + + # Refresh to ensure we have latest column information + await self.refresh(fb=NoFeedback) + column_id_map = self._get_column_id_map() + + fb.progress(0.2, "Creating report...") + + context = self._get_context() + report = await Report.create( + context=context, + block_model_uuid=self._metadata.id, + data=data, + column_id_map=column_id_map, + fb=fb, + block_model_name=self.name, + ) + + return report + + async def list_reports(self, fb: IFeedback = NoFeedback) -> list[Report]: + """List all report specifications for this block model. + + :param fb: Optional feedback interface for progress reporting. + :return: List of Report instances. + """ + fb.progress(0.0, "Fetching reports...") + + environment = self._client._environment + context = self._get_context() + + result = await self._client._reports_api.list_block_model_report_specifications( + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._metadata.id), + ) + + fb.progress(1.0, f"Found {result.total} reports") + + return [Report(context, self._metadata.id, spec, block_model_name=self.name) for spec in result.results] + + # ---- Refresh ---- + + async def refresh(self, fb: IFeedback = NoFeedback) -> None: + """Refresh the block model data from the server. + + :param fb: Optional feedback interface for progress reporting. + """ + fb.progress(0.0, "Refreshing block model...") + + self._metadata = await self._client.get_block_model(self._metadata.id) + + table = await self._client.query_block_model_as_table( + bm_id=self._metadata.id, + columns=["*"], + ) + self._cell_data = pyarrow_to_dataframe(table) + + versions = await self._client.list_versions(self._metadata.id) + if versions: + self._version = versions[0] + + fb.progress(1.0, "Block model refreshed") + + # ---- Internal helpers ---- + + @abstractmethod + def _get_context(self) -> IContext: + """Get the IContext for this block model. + + Subclasses must implement this to provide the context used for report creation + and other operations that require it. + """ + ... diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/regular_block_model.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/regular_block_model.py new file mode 100644 index 00000000..f35a6df3 --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/regular_block_model.py @@ -0,0 +1,286 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed access for regular block models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from uuid import UUID + +import pandas as pd + +from evo.common import IContext, IFeedback, StaticContext +from evo.common.utils import NoFeedback + +from ..client import BlockModelAPIClient +from ..data import BlockModel, RegularGridDefinition, Version +from ..endpoints.models import BBox, BBoxXYZ, RotationAxis +from ._utils import dataframe_to_pyarrow, pyarrow_to_dataframe +from .base import BaseTypedBlockModel +from .types import Point3, Size3d, Size3i + +__all__ = [ + "RegularBlockModel", + "RegularBlockModelData", +] + + +@dataclass(frozen=True, kw_only=True) +class RegularBlockModelData: + """Data class for creating a new regular block model. + + :param name: The name of the block model. + :param origin: The origin point of the block model grid. + :param n_blocks: The number of blocks in each dimension (nx, ny, nz). + :param block_size: The size of each block in each dimension (dx, dy, dz). + :param rotations: List of rotations as (axis, angle) tuples. Angle is in degrees, + positive angles indicate clockwise rotation when looking down the axis. + :param cell_data: Optional DataFrame containing block attribute data. + Must include geometry columns (i, j, k) or (x, y, z) and attribute columns. + :param description: Optional description of the block model. + :param coordinate_reference_system: Optional coordinate reference system (e.g., "EPSG:4326"). + :param size_unit_id: Optional unit identifier for block sizes (e.g., "m"). + :param units: Optional dictionary mapping column names to unit identifiers. + """ + + name: str + origin: Point3 + n_blocks: Size3i + block_size: Size3d + rotations: list[tuple[RotationAxis, float]] = field(default_factory=list) + cell_data: pd.DataFrame | None = None + description: str | None = None + coordinate_reference_system: str | None = None + size_unit_id: str | None = None + units: dict[str, str] = field(default_factory=dict) + + +class RegularBlockModel(BaseTypedBlockModel): + """A typed wrapper for regular block models providing pandas DataFrame access. + + This class provides a high-level interface for creating, retrieving, and updating + regular block models with typed access to grid properties and cell data. + + Example usage: + + # Create a new block model + data = RegularBlockModelData( + name="My Block Model", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + cell_data=my_dataframe, + ) + block_model = await RegularBlockModel.create(context, data) + + # Retrieve an existing block model + block_model = await RegularBlockModel.get(context, bm_id) + df = block_model.cell_data + + # Update attributes + new_version = await block_model.update_attributes( + updated_dataframe, + new_columns=["new_col"], + ) + """ + + def __init__( + self, + client: BlockModelAPIClient, + metadata: BlockModel, + version: Version, + cell_data: pd.DataFrame, + context: IContext | None = None, + ) -> None: + """Initialize a RegularBlockModel instance. + + :param client: The BlockModelAPIClient used for API operations. + :param metadata: The block model metadata. + :param version: The current version information. + :param cell_data: The cell data as a pandas DataFrame. + :param context: Optional IContext for report and other operations. + """ + super().__init__(client=client, metadata=metadata, version=version, cell_data=cell_data) + self._context = context + + @property + def n_blocks(self) -> Size3i: + """The number of blocks in each dimension.""" + grid_def = self._metadata.grid_definition + if not isinstance(grid_def, RegularGridDefinition): + raise TypeError("Block model is not a regular grid") + return Size3i( + nx=grid_def.n_blocks[0], + ny=grid_def.n_blocks[1], + nz=grid_def.n_blocks[2], + ) + + @property + def block_size(self) -> Size3d: + """The size of each block in each dimension.""" + grid_def = self._metadata.grid_definition + if not isinstance(grid_def, RegularGridDefinition): + raise TypeError("Block model is not a regular grid") + return Size3d( + dx=grid_def.block_size[0], + dy=grid_def.block_size[1], + dz=grid_def.block_size[2], + ) + + @property + def rotations(self) -> list[tuple[RotationAxis, float]]: + """The rotations applied to the block model.""" + return list(self._metadata.grid_definition.rotations) + + def _get_context(self) -> IContext: + """Get the IContext for this block model.""" + if self._context is not None: + return self._context + # Build a context from the client's internal state + return StaticContext.from_environment( + environment=self._client._environment, + connector=self._client._connector, + cache=self._client._cache, + ) + + @classmethod + async def create( + cls, + context: IContext, + data: RegularBlockModelData, + path: str | None = None, + fb: IFeedback = NoFeedback, + ) -> RegularBlockModel: + """Create a new regular block model. + + :param context: The context containing environment, connector, and cache. + :param data: The data defining the block model to create. + :param path: Optional path for the block model in the workspace. + :param fb: Optional feedback interface for progress reporting. + :return: A RegularBlockModel instance representing the created block model. + :raises ValueError: If the data is invalid. + """ + client = BlockModelAPIClient.from_context(context) + + # Create the grid definition + grid_definition = RegularGridDefinition( + model_origin=[data.origin.x, data.origin.y, data.origin.z], + rotations=list(data.rotations), + n_blocks=[data.n_blocks.nx, data.n_blocks.ny, data.n_blocks.nz], + block_size=[data.block_size.dx, data.block_size.dy, data.block_size.dz], + ) + + fb.progress(0.0, "Creating block model...") + + # Convert DataFrame to PyArrow Table if cell data is provided + initial_data = None + if data.cell_data is not None: + initial_data = dataframe_to_pyarrow(data.cell_data) + + # Create the block model with initial data (if provided) + bm, version = await client.create_block_model( + name=data.name, + description=data.description, + grid_definition=grid_definition, + object_path=path, + coordinate_reference_system=data.coordinate_reference_system, + size_unit_id=data.size_unit_id, + initial_data=initial_data, + units=data.units if data.units else None, + ) + + fb.progress(1.0, "Block model created successfully") + + # Retrieve the cell data (or create empty DataFrame) + if data.cell_data is not None: + cell_data = data.cell_data.copy() + else: + cell_data = pd.DataFrame() + + return cls( + client=client, + metadata=bm, + version=version, + cell_data=cell_data, + context=context, + ) + + @classmethod + async def get( + cls, + context: IContext, + bm_id: UUID, + version_id: UUID | None = None, + columns: list[str] | None = None, + bbox: BBox | BBoxXYZ | None = None, + fb: IFeedback = NoFeedback, + ) -> RegularBlockModel: + """Retrieve an existing regular block model. + + :param context: The context containing environment, connector, and cache. + :param bm_id: The UUID of the block model to retrieve. + :param version_id: Optional version UUID. Defaults to the latest version. + :param columns: Optional list of columns to retrieve. Defaults to all columns ["*"]. + :param bbox: Optional bounding box to filter the data. + :param fb: Optional feedback interface for progress reporting. + :return: A RegularBlockModel instance. + :raises ValueError: If the block model is not a regular grid. + """ + client = BlockModelAPIClient.from_context(context) + + fb.progress(0.0, "Retrieving block model metadata...") + + # Get block model metadata + bm = await client.get_block_model(bm_id) + + # Verify it's a regular grid + if not isinstance(bm.grid_definition, RegularGridDefinition): + raise ValueError(f"Block model {bm_id} is not a regular grid. Got {type(bm.grid_definition).__name__}") + + fb.progress(0.2, "Querying block model data...") + + # Default to all columns if not specified + if columns is None: + columns = ["*"] + + # Query the block model data + table = await client.query_block_model_as_table( + bm_id=bm_id, + columns=columns, + bbox=bbox, + version_uuid=version_id, + ) + + fb.progress(0.8, "Converting data...") + + # Convert to DataFrame + cell_data = pyarrow_to_dataframe(table) + + # Get version information + versions = await client.list_versions(bm_id) + if version_id is not None: + version = next( + (v for v in versions if v.version_uuid == version_id), + versions[0] if versions else None, + ) + else: + version = versions[0] if versions else None + + fb.progress(1.0, "Block model retrieved successfully") + + return cls( + client=client, + metadata=bm, + version=version, + cell_data=cell_data, + context=context, + ) diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/report.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/report.py new file mode 100644 index 00000000..3a0fc0f5 --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/report.py @@ -0,0 +1,644 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed access for block model reports. + +Reports provide resource estimation summaries for block models, allowing you to +calculate tonnages, grades, and metal content by category (e.g., geological domains). + +Reports require: +1. Columns to have units set (e.g., grade in g/t, density in t/m³) +2. At least one category column for grouping (e.g., domain, rock type) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING, Any +from uuid import UUID + +import pandas as pd + +from evo.common import IContext, IFeedback +from evo.common.utils import NoFeedback + +if TYPE_CHECKING: + from ..client import BlockModelAPIClient + from ..endpoints.models import ( + ReportResult as APIReportResult, + ) + from ..endpoints.models import ( + ReportSpecificationWithJobUrl, + ReportSpecificationWithLastRunInfo, + ) + +__all__ = [ + "Aggregation", + "MassUnits", + "Report", + "ReportCategorySpec", + "ReportColumnSpec", + "ReportResult", + "ReportSpecificationData", +] + + +class Aggregation(str, Enum): + """Aggregation methods for report columns. + + Use these values for the `aggregation` parameter in `ReportColumnSpec`. + + Example: + >>> col = ReportColumnSpec( + ... column_name="Au", + ... aggregation=Aggregation.MASS_AVERAGE, + ... output_unit_id="g/t", + ... ) + """ + + SUM = "SUM" + """Sum of values - use for metal content, volume, tonnage, etc.""" + + MASS_AVERAGE = "MASS_AVERAGE" + """Mass-weighted average - use for grades, densities, quality metrics, etc.""" + + +@dataclass(frozen=True, kw_only=True) +class ReportColumnSpec: + """Specification for a column in a report. + + :param column_name: The name of the column in the block model. + :param aggregation: How to aggregate the column values. Use `Aggregation` enum: + - `Aggregation.MASS_AVERAGE` - Mass-weighted average (for grades) + - `Aggregation.SUM` - Sum of values (for metal content) + :param label: Display label for the column in the report. + :param output_unit_id: Unit ID for the output values. Use `Units` class constants: + - `Units.GRAMS_PER_TONNE` - g/t (grades) + - `Units.PERCENT` - % (grades) + - `Units.PPM` - ppm (grades) + - `Units.KILOGRAMS` - kg (metal content) + - `Units.TONNES` - t (metal content) + - `Units.TROY_OUNCES` - oz_tr (metal content) + + Example: + >>> from evo.blockmodels import Units + >>> from evo.blockmodels.typed import Aggregation, ReportColumnSpec + >>> + >>> # For grade columns, use MASS_AVERAGE + >>> grade_col = ReportColumnSpec( + ... column_name="Au", + ... aggregation=Aggregation.MASS_AVERAGE, + ... label="Au Grade", + ... output_unit_id=Units.GRAMS_PER_TONNE, + ... ) + >>> # For metal content columns, use SUM + >>> metal_col = ReportColumnSpec( + ... column_name="Au_metal", + ... aggregation=Aggregation.SUM, + ... label="Au Metal", + ... output_unit_id=Units.KILOGRAMS, + ... ) + """ + + column_name: str + aggregation: Aggregation = Aggregation.SUM + label: str | None = None + output_unit_id: str | None = None + + def _get_label(self) -> str: + """Get the label, defaulting to column_name if not set.""" + return self.label or self.column_name + + +@dataclass(frozen=True, kw_only=True) +class ReportCategorySpec: + """Specification for a category column in a report. + + Category columns are used to group blocks for reporting (e.g., by domain, rock type). + + :param column_name: The name of the category column in the block model. + :param label: Display label for the category in the report. + :param values: Optional list of category values to include. If None, all values are included. + """ + + column_name: str + label: str | None = None + values: list[str] | None = None + + def _get_label(self) -> str: + """Get the label, defaulting to column_name if not set.""" + return self.label or self.column_name + + +class MassUnits: + """Common mass unit IDs for reports. + + Use these constants for the `mass_unit_id` parameter in `ReportSpecificationData`. + + Example: + >>> report_data = ReportSpecificationData( + ... name="My Report", + ... columns=[...], + ... mass_unit_id=MassUnits.TONNES, + ... ) + """ + + TONNES = "t" + """Metric tonnes""" + + KILOGRAMS = "kg" + """Kilograms""" + + GRAMS = "g" + """Grams""" + + OUNCES = "oz" + """Troy ounces""" + + POUNDS = "lb" + """Pounds""" + + +@dataclass(frozen=True, kw_only=True) +class ReportSpecificationData: + """Data for creating a report specification. + + A report specification defines how to calculate resource estimates from a block model. + It includes which columns to report on, how to categorize blocks, and density/mass settings. + + :param name: The name of the report. + :param columns: List of columns to include in the report with their aggregation settings. + Use `ReportColumnSpec` to define each column. + :param mass_unit_id: Unit ID for mass output. Common values: + - "t" (tonnes) - use `MassUnits.TONNES` + - "kg" (kilograms) - use `MassUnits.KILOGRAMS` + - "oz" (ounces) - use `MassUnits.OUNCES` + :param categories: List of category columns for grouping blocks. + Use `ReportCategorySpec` to define each category. + :param description: Optional description of the report. + :param density_value: Fixed density value (requires `density_unit_id`). + Do NOT use with `density_column_name`. + :param density_unit_id: Unit ID for fixed density (e.g., "t/m3"). + Only use with `density_value`, NOT with `density_column_name`. + :param density_column_name: Name of the column containing block densities. + Do NOT use with `density_value` or `density_unit_id`. + :param cutoff_column_name: Name of the column to use for cut-off evaluation. + :param cutoff_values: List of cut-off values to evaluate. + :param autorun: Whether to automatically run the report when block model is updated. + :param run_now: Whether to run the report immediately after creation. + + Example with density column: + >>> data = ReportSpecificationData( + ... name="Gold Resource Report", + ... columns=[ + ... ReportColumnSpec( + ... column_name="Au", + ... aggregation="MASS_AVERAGE", # Use for grades + ... label="Au Grade", + ... output_unit_id="g/t", + ... ), + ... ], + ... categories=[ + ... ReportCategorySpec(column_name="domain", label="Domain"), + ... ], + ... mass_unit_id=MassUnits.TONNES, + ... density_column_name="density", # Unit comes from column + ... ) + + Example with fixed density: + >>> data = ReportSpecificationData( + ... name="Gold Resource Report", + ... columns=[...], + ... categories=[...], + ... mass_unit_id=MassUnits.TONNES, + ... density_value=2.7, # Fixed density + ... density_unit_id="t/m3", # Required with density_value + ... ) + """ + + name: str + columns: list[ReportColumnSpec] + mass_unit_id: str + + categories: list[ReportCategorySpec] = field(default_factory=list) + description: str | None = None + density_value: float | None = None + density_unit_id: str | None = None + density_column_name: str | None = None + cutoff_column_name: str | None = None + cutoff_values: list[float] | None = None + autorun: bool = True + run_now: bool = True + + +@dataclass(frozen=True, kw_only=True) +class ReportResult: + """A result from running a report. + + Contains the calculated values for each category and cut-off combination. + """ + + result_uuid: UUID + report_specification_uuid: UUID + block_model_uuid: UUID + version_id: int + version_uuid: UUID | None + created_at: datetime + categories: list[dict[str, Any]] + columns: list[dict[str, Any]] + result_sets: list[dict[str, Any]] + + @classmethod + def _from_api_result(cls, result: "APIReportResult") -> "ReportResult": + """Create a ReportResult from an API response.""" + return cls( + result_uuid=result.report_result_uuid, + report_specification_uuid=result.report_specification_uuid, + block_model_uuid=result.bm_uuid, + version_id=result.version_id, + version_uuid=result.version_uuid, + created_at=result.report_result_created_at, + categories=[cat.model_dump() for cat in result.categories], + columns=[col.model_dump() for col in result.value_columns], + result_sets=[rs.model_dump() for rs in result.result_sets], + ) + + def to_dataframe(self) -> pd.DataFrame: + """Convert the report result to a pandas DataFrame. + + Returns a DataFrame with one row per category/cut-off combination, + containing the aggregated values for each report column. + + :return: DataFrame with report results. + """ + rows = [] + column_labels = [col.get("label", f"Column {i}") for i, col in enumerate(self.columns)] + + for result_set in self.result_sets: + cutoff = result_set.get("cutoff_value") + for row_data in result_set.get("rows", []): + row = {"cutoff": cutoff} + # Add category values + cat_values = row_data.get("categories", []) + for i, cat in enumerate(self.categories): + cat_label = cat.get("label", f"Category {i}") + row[cat_label] = cat_values[i] if i < len(cat_values) else None + # Add column values + values = row_data.get("values", []) + for i, label in enumerate(column_labels): + row[label] = values[i] if i < len(values) else None + rows.append(row) + + return pd.DataFrame(rows) + + def __repr__(self) -> str: + """Return a string representation of the report result.""" + df = self.to_dataframe() + return ( + f"ReportResult(version={self.version_id}, " + f"created={self.created_at.strftime('%Y-%m-%d %H:%M:%S')}, " + f"rows={len(df)})\n{df.to_string()}" + ) + + +class Report: + """A typed wrapper for block model report specifications. + + Reports provide resource estimation summaries for block models. They calculate + tonnages, grades, and metal content grouped by categories (e.g., geological domains). + + Example usage: + + # Create a report from a block model + report = await block_model.create_report(ReportSpecificationData( + name="Resource Report", + columns=[ReportColumnSpec(column_name="Au", output_unit_id="g/t")], + categories=[ReportCategorySpec(column_name="domain")], + mass_unit_id="t", + density_value=2.7, + density_unit_id="t/m3", + )) + + # Pretty-print shows BlockSync link + report + + # Get the latest result + result = await report.get_latest_result() + df = result.to_dataframe() + """ + + def __init__( + self, + context: IContext, + block_model_uuid: UUID, + specification: "ReportSpecificationWithLastRunInfo | ReportSpecificationWithJobUrl", + block_model_name: str | None = None, + ) -> None: + """Initialize a Report instance. + + :param context: The context containing environment, connector, and cache. + :param block_model_uuid: The UUID of the block model this report is for. + :param specification: The report specification from the API. + :param block_model_name: The name of the block model (for display purposes). + """ + self._context = context + self._block_model_uuid = block_model_uuid + self._specification = specification + self._block_model_name = block_model_name + + @property + def id(self) -> UUID: + """The unique identifier of the report specification.""" + return self._specification.report_specification_uuid + + @property + def name(self) -> str: + """The name of the report.""" + return self._specification.name + + @property + def description(self) -> str | None: + """The description of the report.""" + return self._specification.description + + @property + def block_model_uuid(self) -> UUID: + """The UUID of the block model this report is for.""" + return self._block_model_uuid + + @property + def revision(self) -> int: + """The revision number of the report specification.""" + return self._specification.revision + + def _get_client(self) -> "BlockModelAPIClient": + """Get a BlockModelAPIClient for the current context.""" + from ..client import BlockModelAPIClient + + return BlockModelAPIClient.from_context(self._context) + + @classmethod + async def create( + cls, + context: IContext, + block_model_uuid: UUID, + data: ReportSpecificationData, + column_id_map: dict[str, UUID], + fb: IFeedback = NoFeedback, + block_model_name: str | None = None, + ) -> "Report": + """Create a new report specification. + + :param context: The context containing environment, connector, and cache. + :param block_model_uuid: The UUID of the block model to create the report for. + :param data: The report specification data. + :param column_id_map: Mapping of column names to their UUIDs in the block model. + :param fb: Optional feedback interface for progress reporting. + :param block_model_name: The name of the block model (for display purposes). + :return: A Report instance representing the created report. + """ + from ..endpoints.models import ( + CreateReportSpecification, + ReportAggregation, + ReportCategory, + ReportColumn, + ) + + fb.progress(0.0, "Creating report specification...") + + # Build columns list + columns = [] + for col_spec in data.columns: + col_id = column_id_map.get(col_spec.column_name) + if col_id is None: + raise ValueError(f"Column '{col_spec.column_name}' not found in block model") + columns.append( + ReportColumn( + col_id=col_id, + label=col_spec._get_label(), + aggregation=ReportAggregation(col_spec.aggregation.value), + output_unit_id=col_spec.output_unit_id or "", + ) + ) + + # Build categories list + categories = None + if data.categories: + categories = [] + for cat_spec in data.categories: + col_id = column_id_map.get(cat_spec.column_name) + if col_id is None: + raise ValueError(f"Category column '{cat_spec.column_name}' not found in block model") + categories.append( + ReportCategory( + col_id=col_id, + label=cat_spec._get_label(), + values=cat_spec.values, + ) + ) + + # Build cutoff settings + cutoff_col_id = None + if data.cutoff_column_name: + cutoff_col_id = column_id_map.get(data.cutoff_column_name) + if cutoff_col_id is None: + raise ValueError(f"Cut-off column '{data.cutoff_column_name}' not found in block model") + + # Build density settings + density_col_id = None + if data.density_column_name: + density_col_id = column_id_map.get(data.density_column_name) + if density_col_id is None: + raise ValueError(f"Density column '{data.density_column_name}' not found in block model") + + # Create the specification + spec = CreateReportSpecification( + name=data.name, + description=data.description, + columns=columns, + categories=categories, + mass_unit_id=data.mass_unit_id, + density_value=data.density_value, + density_unit_id=data.density_unit_id, + density_col_id=density_col_id, + cutoff_col_id=cutoff_col_id, + cutoff_values=data.cutoff_values, + autorun=data.autorun, + ) + + fb.progress(0.3, "Submitting to Block Model Service...") + + # Call the API + from ..client import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(context) + environment = context.get_environment() + + result = await client._reports_api.create_report_specification( + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(block_model_uuid), + create_report_specification=spec, + run_now=data.run_now, + ) + + fb.progress(1.0, "Report specification created") + + return cls(context, block_model_uuid, result, block_model_name=block_model_name) + + async def run(self, version_uuid: UUID | None = None, fb: IFeedback = NoFeedback) -> ReportResult: + """Run the report to generate a new result. + + :param version_uuid: Optional specific version UUID to run the report on. + If None, runs on the latest version. + :param fb: Optional feedback interface for progress reporting. + :return: The generated report result. + """ + from ..endpoints.models import ReportingJobSpec + + fb.progress(0.0, "Running report...") + + client = self._get_client() + environment = self._context.get_environment() + + # Create job spec + job_spec = ReportingJobSpec(version_uuid=version_uuid) + + # Run the job + job_result = await client._reports_api.run_reporting_job( + rs_id=str(self.id), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + reporting_job_spec=job_spec, + ) + + fb.progress(0.5, "Fetching result...") + + # Get the full result + result = await client._reports_api.get_report_result( + rs_id=str(self.id), + report_result_uuid=str(job_result.report_result_uuid), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + ) + + fb.progress(1.0, "Report complete") + + return ReportResult._from_api_result(result) + + async def refresh( + self, + fb: IFeedback = NoFeedback, + timeout_seconds: float = 120.0, + poll_interval_seconds: float = 2.0, + ) -> ReportResult: + """Get the most recent result for this report, waiting if necessary. + + If no results exist yet (e.g., report is still running), this method will + poll until a result is available or the timeout is reached. + + :param fb: Optional feedback interface for progress reporting. + :param timeout_seconds: Maximum time to wait for results (default 120 seconds). + :param poll_interval_seconds: Time between polling attempts (default 2 seconds). + :return: The latest report result. + :raises TimeoutError: If no results are available within the timeout period. + """ + import asyncio + import time + + fb.progress(0.0, "Fetching latest result...") + + client = self._get_client() + environment = self._context.get_environment() + + start_time = time.time() + attempt = 0 + + while True: + attempt += 1 + elapsed = time.time() - start_time + + # List results (ordered newest first) + results_list = await client._reports_api.get_report_results_list( + rs_id=str(self.id), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + limit=1, + ) + + if results_list.results: + # Get the full result + latest = results_list.results[0] + result = await client._reports_api.get_report_result( + rs_id=str(self.id), + report_result_uuid=str(latest.report_result_uuid), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + ) + + fb.progress(1.0, "Result fetched") + return ReportResult._from_api_result(result) + + # Check timeout + if elapsed >= timeout_seconds: + raise TimeoutError( + f"No report results available after {timeout_seconds} seconds. " + "The report may still be running - try again later." + ) + + # Report progress and wait + progress = min(0.9, elapsed / timeout_seconds) + fb.progress(progress, f"Waiting for results (attempt {attempt})...") + await asyncio.sleep(poll_interval_seconds) + + async def list_results(self, limit: int = 50, fb: IFeedback = NoFeedback) -> list[ReportResult]: + """List all results for this report. + + :param limit: Maximum number of results to return. + :param fb: Optional feedback interface for progress reporting. + :return: List of report results, ordered newest first. + """ + fb.progress(0.0, "Fetching results...") + + client = self._get_client() + environment = self._context.get_environment() + + # List results + results_list = await client._reports_api.get_report_results_list( + rs_id=str(self.id), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + limit=limit, + ) + + fb.progress(0.5, f"Fetching {len(results_list.results)} results...") + + # Get full results + results = [] + for i, summary in enumerate(results_list.results): + result = await client._reports_api.get_report_result( + rs_id=str(self.id), + report_result_uuid=str(summary.report_result_uuid), + workspace_id=str(environment.workspace_id), + org_id=str(environment.org_id), + bm_id=str(self._block_model_uuid), + ) + results.append(ReportResult._from_api_result(result)) + fb.progress(0.5 + 0.5 * (i + 1) / len(results_list.results), f"Fetched {i + 1}/{len(results_list.results)}") + + return results diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/types.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/types.py new file mode 100644 index 00000000..397b6242 --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/types.py @@ -0,0 +1,24 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Type definitions for typed block model access. + +These types are re-exported from evo.common.typed for backward compatibility. +""" + +from evo.common.typed import BoundingBox, Point3, Size3d, Size3i + +__all__ = [ + "BoundingBox", + "Point3", + "Size3d", + "Size3i", +] diff --git a/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py new file mode 100644 index 00000000..92863c0a --- /dev/null +++ b/packages/evo-blockmodels/src/evo/blockmodels/typed/units.py @@ -0,0 +1,175 @@ +# Copyright © 2025 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Common unit IDs for block model attributes. + +Unit IDs must match the values supported by the Block Model Service. +Use `get_available_units()` to retrieve the full list of available units from the service. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from evo.common import IContext + +__all__ = [ + "UnitInfo", + "UnitType", + "Units", + "get_available_units", +] + + +class UnitType(Enum): + """Types of units supported by the Block Model Service.""" + + LENGTH = "LENGTH" + MASS = "MASS" + VOLUME = "VOLUME" + 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) +class UnitInfo: + """Information about a unit.""" + + unit_id: str + """The unit ID to use when setting column units.""" + + symbol: str + """Display symbol for the unit.""" + + description: str + """Human-readable description of the unit.""" + + unit_type: UnitType + """The type/category of this unit.""" + + conversion_factor: float + """Conversion factor to the reference unit for this unit type.""" + + +class Units: + """Common unit IDs for block model attributes. + + These are the most commonly used unit IDs. For a complete list, + use `get_available_units()` to query the Block Model Service. + + Example usage: + from evo.blockmodels.typed import Units + + # Create block model with units + bm_data = RegularBlockModelData( + ... + units={ + "grade": Units.GRAMS_PER_TONNE, + "density": Units.TONNES_PER_CUBIC_METRE, + }, + ) + + # Add attribute with unit + await bm_ref.add_attribute(df, "metal_content", unit=Units.KILOS_PER_CUBIC_METRE) + """ + + # Length units + METRES = "m" + FEET = "ft" + CENTIMETRES = "cm" + + # Mass units + CARATS = "ct" + GRAMS = "g" + POUNDS = "lbm" + TROY_OUNCES = "ozm[troy]" + TONNES = "t" + 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" + THOUSAND_CARATS = "1000 ct" + THOUSAND_TROY_OUNCES = "1000 ozm[troy]" + 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" + + # 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" + + # 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. + + :param context: The context containing environment and connector. + :return: List of available units. + + Example: + units = await get_available_units(manager) + for unit in units: + print(f"{unit.unit_id}: {unit.description} ({unit.symbol})") + """ + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(context) + units_api = client._units_api + + units_response = await units_api.get_units( + org_id=str(context.get_environment().org_id), + ) + + return [ + UnitInfo( + unit_id=u.unit_id, + symbol=u.symbol, + description=u.description, + unit_type=UnitType(u.unit_type.value), + conversion_factor=u.conversion_factor, + ) + for u in units_response + ] diff --git a/packages/evo-blockmodels/tests/test_data_repr.py b/packages/evo-blockmodels/tests/test_data_repr.py new file mode 100644 index 00000000..34fda82b --- /dev/null +++ b/packages/evo-blockmodels/tests/test_data_repr.py @@ -0,0 +1,297 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for pretty printing of block model related data classes.""" + +import unittest +from datetime import datetime, timezone +from uuid import uuid4 + +from evo.blockmodels.data import ( + BlockModel, + FlexibleGridDefinition, + FullySubBlockedGridDefinition, + OctreeGridDefinition, + RegularGridDefinition, + Version, +) +from evo.blockmodels.endpoints.models import ( + BBox, + BBoxXYZ, + Column, + DataType, + FloatRange, + IntRange, + RotationAxis, +) +from evo.common import Environment +from evo.workspaces import ServiceUser + + +class TestVersionRepr(unittest.TestCase): + """Test pretty printing for Version class.""" + + def _create_test_version( + self, + version_id: int = 3, + with_bbox: bool = True, + with_comment: str = "Test comment", + ) -> Version: + """Create a Version object for testing.""" + columns = [ + Column(col_id="i", data_type=DataType.UInt32, title="i", unit_id=None), + Column(col_id="j", data_type=DataType.UInt32, title="j", unit_id=None), + Column(col_id="k", data_type=DataType.UInt32, title="k", unit_id=None), + Column(col_id=str(uuid4()), data_type=DataType.Float64, title="grade", unit_id="g/t"), + Column(col_id=str(uuid4()), data_type=DataType.Float64, title="density", unit_id="t/m3"), + ] + + bbox = None + if with_bbox: + bbox = BBox( + i_minmax=IntRange(min=0, max=9), + j_minmax=IntRange(min=0, max=9), + k_minmax=IntRange(min=0, max=4), + ) + + return Version( + bm_uuid=uuid4(), + version_id=version_id, + version_uuid=uuid4(), + parent_version_id=version_id - 1 if version_id > 1 else 0, + base_version_id=version_id - 1 if version_id > 1 else None, + geoscience_version_id="1770234750628962917", + created_at=datetime(2026, 2, 4, 19, 52, 30, 120561, tzinfo=timezone.utc), + created_by=ServiceUser(id=uuid4(), name="Denis Simo", email="Denis.Simo@bentley.com"), + comment=with_comment, + bbox=bbox, + columns=columns, + ) + + def test_repr_returns_concise_string(self) -> None: + """Test that __repr__ returns a concise, readable string.""" + version = self._create_test_version() + repr_str = repr(version) + + # Should contain key info + self.assertIn("Version(id=3", repr_str) + self.assertIn("created=2026-02-04 19:52:30", repr_str) + self.assertIn("by=Denis Simo", repr_str) + self.assertIn("bbox=i[0-9] j[0-9] k[0-4]", repr_str) + self.assertIn("columns=['i', 'j', 'k', 'grade', 'density']", repr_str) + + def test_repr_without_bbox(self) -> None: + """Test that __repr__ works when bbox is None.""" + version = self._create_test_version(with_bbox=False) + repr_str = repr(version) + + self.assertIn("Version(id=3", repr_str) + self.assertNotIn("bbox=i[", repr_str) + + def test_repr_with_email_fallback(self) -> None: + """Test that repr falls back to email when name is None.""" + columns = [ + Column(col_id="i", data_type=DataType.UInt32, title="i", unit_id=None), + ] + version = Version( + bm_uuid=uuid4(), + version_id=1, + version_uuid=uuid4(), + parent_version_id=0, + base_version_id=None, + geoscience_version_id="123", + created_at=datetime(2026, 2, 4, 19, 52, 30, tzinfo=timezone.utc), + created_by=ServiceUser(id=uuid4(), name=None, email="test@example.com"), + comment="", + bbox=None, + columns=columns, + ) + repr_str = repr(version) + self.assertIn("by=test@example.com", repr_str) + + +class TestGridDefinitionRepr(unittest.TestCase): + """Test repr for grid definition classes.""" + + def test_regular_grid_definition_default_repr(self) -> None: + """Test that RegularGridDefinition has a readable default repr.""" + grid = RegularGridDefinition( + model_origin=[0.0, 0.0, 0.0], + rotations=[(RotationAxis.x, 0.0)], + n_blocks=[10, 10, 5], + block_size=[1.0, 1.0, 2.0], + ) + repr_str = repr(grid) + + # Default dataclass repr should include key fields + self.assertIn("RegularGridDefinition", repr_str) + self.assertIn("n_blocks=[10, 10, 5]", repr_str) + self.assertIn("block_size=[1.0, 1.0, 2.0]", repr_str) + + def test_fully_subblocked_grid_definition_default_repr(self) -> None: + """Test that FullySubBlockedGridDefinition has a readable default repr.""" + grid = FullySubBlockedGridDefinition( + model_origin=[0.0, 0.0, 0.0], + rotations=[], + n_parent_blocks=[5, 5, 5], + n_subblocks_per_parent=[2, 2, 2], + parent_block_size=[10.0, 10.0, 10.0], + ) + repr_str = repr(grid) + + self.assertIn("FullySubBlockedGridDefinition", repr_str) + self.assertIn("n_parent_blocks=[5, 5, 5]", repr_str) + self.assertIn("n_subblocks_per_parent=[2, 2, 2]", repr_str) + + def test_flexible_grid_definition_default_repr(self) -> None: + """Test that FlexibleGridDefinition has a readable default repr.""" + grid = FlexibleGridDefinition( + model_origin=[100.0, 200.0, 300.0], + rotations=[(RotationAxis.z, 45.0)], + n_parent_blocks=[8, 8, 4], + n_subblocks_per_parent=[4, 4, 2], + parent_block_size=[5.0, 5.0, 10.0], + ) + repr_str = repr(grid) + + self.assertIn("FlexibleGridDefinition", repr_str) + self.assertIn("n_parent_blocks=[8, 8, 4]", repr_str) + + def test_octree_grid_definition_default_repr(self) -> None: + """Test that OctreeGridDefinition has a readable default repr.""" + grid = OctreeGridDefinition( + model_origin=[0.0, 0.0, 0.0], + rotations=[], + n_parent_blocks=[4, 4, 4], + n_subblocks_per_parent=[8, 8, 8], + parent_block_size=[100.0, 100.0, 100.0], + ) + repr_str = repr(grid) + + self.assertIn("OctreeGridDefinition", repr_str) + self.assertIn("n_parent_blocks=[4, 4, 4]", repr_str) + + +class TestBlockModelRepr(unittest.TestCase): + """Test repr for BlockModel class.""" + + def _create_test_environment(self) -> Environment: + """Create a test environment.""" + return Environment( + hub_url="https://example.evo.bentley.com", + org_id=uuid4(), + workspace_id=uuid4(), + ) + + def _create_test_block_model(self) -> BlockModel: + """Create a BlockModel object for testing.""" + return BlockModel( + id=uuid4(), + name="Test Block Model", + environment=self._create_test_environment(), + created_at=datetime(2026, 1, 15, 10, 30, 0, tzinfo=timezone.utc), + created_by=ServiceUser(id=uuid4(), name="Creator", email="creator@example.com"), + geoscience_object_id=uuid4(), + description="A test block model for unit testing", + grid_definition=RegularGridDefinition( + model_origin=[1000.0, 2000.0, -500.0], + rotations=[(RotationAxis.z, 15.0)], + n_blocks=[100, 100, 50], + block_size=[5.0, 5.0, 10.0], + ), + coordinate_reference_system="EPSG:32650", + size_unit_id="m", + bbox=BBoxXYZ( + x_minmax=FloatRange(min=1000.0, max=1500.0), + y_minmax=FloatRange(min=2000.0, max=2500.0), + z_minmax=FloatRange(min=-500.0, max=0.0), + ), + last_updated_at=datetime(2026, 2, 1, 14, 45, 30, tzinfo=timezone.utc), + last_updated_by=ServiceUser(id=uuid4(), name="Updater", email="updater@example.com"), + ) + + def test_block_model_default_repr(self) -> None: + """Test that BlockModel has a readable default repr.""" + bm = self._create_test_block_model() + repr_str = repr(bm) + + # Default dataclass repr should include key fields + self.assertIn("BlockModel", repr_str) + self.assertIn("Test Block Model", repr_str) + self.assertIn("RegularGridDefinition", repr_str) + + +class TestBBoxRepr(unittest.TestCase): + """Test repr for BBox and BBoxXYZ classes.""" + + def test_bbox_repr(self) -> None: + """Test that BBox has a readable repr.""" + bbox = BBox( + i_minmax=IntRange(min=0, max=99), + j_minmax=IntRange(min=0, max=99), + k_minmax=IntRange(min=0, max=49), + ) + repr_str = repr(bbox) + + self.assertIn("BBox", repr_str) + self.assertIn("i_minmax", repr_str) + self.assertIn("j_minmax", repr_str) + self.assertIn("k_minmax", repr_str) + + def test_bbox_xyz_repr(self) -> None: + """Test that BBoxXYZ has a readable repr.""" + bbox = BBoxXYZ( + x_minmax=FloatRange(min=0.0, max=1000.0), + y_minmax=FloatRange(min=0.0, max=1000.0), + z_minmax=FloatRange(min=-500.0, max=0.0), + ) + repr_str = repr(bbox) + + self.assertIn("BBoxXYZ", repr_str) + self.assertIn("x_minmax", repr_str) + self.assertIn("y_minmax", repr_str) + self.assertIn("z_minmax", repr_str) + + +class TestColumnRepr(unittest.TestCase): + """Test repr for Column class.""" + + def test_column_repr_with_unit(self) -> None: + """Test that Column with unit has a readable repr.""" + col = Column( + col_id="abc123", + data_type=DataType.Float64, + title="grade", + unit_id="g/t", + ) + repr_str = repr(col) + + self.assertIn("Column", repr_str) + self.assertIn("grade", repr_str) + self.assertIn("Float64", repr_str) + self.assertIn("g/t", repr_str) + + def test_column_repr_without_unit(self) -> None: + """Test that Column without unit has a readable repr.""" + col = Column( + col_id="i", + data_type=DataType.UInt32, + title="i", + unit_id=None, + ) + repr_str = repr(col) + + self.assertIn("Column", repr_str) + self.assertIn("UInt32", repr_str) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/evo-blockmodels/tests/test_typed_regular_block_model.py b/packages/evo-blockmodels/tests/test_typed_regular_block_model.py new file mode 100644 index 00000000..743cb802 --- /dev/null +++ b/packages/evo-blockmodels/tests/test_typed_regular_block_model.py @@ -0,0 +1,1147 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import uuid +from datetime import datetime +from typing import Iterable +from unittest import mock + +import pandas as pd +import pyarrow + +from evo.blockmodels import RegularBlockModel, RegularBlockModelData +from evo.blockmodels.endpoints import models +from evo.blockmodels.endpoints.models import JobResponse, JobStatus, RotationAxis +from evo.blockmodels.typed import Point3, Size3d, Size3i +from evo.common import ServiceUser, StaticContext +from evo.common.data import HTTPHeaderDict, RequestMethod +from evo.common.test_tools import BASE_URL, MockResponse, TestWithConnector, TestWithStorage +from evo.common.utils import get_header_metadata +from utils import JobPollingRequestHandler + +BM_UUID = uuid.uuid4() +GOOSE_UUID = uuid.uuid4() +GOOSE_VERSION_ID = "2" +DATE = datetime(2021, 1, 1) +MODEL_USER = models.UserInfo(email="test@test.com", name="Test User", id=uuid.uuid4()) +USER = ServiceUser.from_model(MODEL_USER) +BM_BBOX = models.BBoxXYZ( + x_minmax=models.FloatRange(min=0, max=10), + y_minmax=models.FloatRange(min=0, max=10), + z_minmax=models.FloatRange(min=0, max=10), +) + + +def _mock_create_result(environment) -> models.BlockModelAndJobURL: + return models.BlockModelAndJobURL( + bbox=BM_BBOX, + block_rotation=[models.Rotation(axis=RotationAxis.x, angle=20)], + bm_uuid=BM_UUID, + name="Test BM", + description="Test Block Model", + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + workspace_id=environment.workspace_id, + org_uuid=environment.org_id, + model_origin=models.Location(x=0, y=0, z=0), + normalized_rotation=[0, 20, 0], + size_options=models.SizeOptionsRegular( + model_type="regular", + n_blocks=models.Size3D(nx=10, ny=10, nz=10), + block_size=models.BlockSize(x=1, y=1, z=1), + ), + geoscience_object_id=GOOSE_UUID, + created_at=DATE, + created_by=MODEL_USER, + last_updated_at=DATE, + last_updated_by=MODEL_USER, + job_url=f"{BASE_URL}/jobs/{uuid.uuid4()}", + ) + + +def _mock_block_model(environment) -> models.BlockModel: + return models.BlockModel( + bbox=BM_BBOX, + block_rotation=[models.Rotation(axis=RotationAxis.x, angle=20)], + bm_uuid=BM_UUID, + name="Test BM", + description="Test Block Model", + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + workspace_id=environment.workspace_id, + org_uuid=environment.org_id, + model_origin=models.Location(x=0, y=0, z=0), + normalized_rotation=[0, 20, 0], + size_options=models.SizeOptionsRegular( + model_type="regular", + n_blocks=models.Size3D(nx=10, ny=10, nz=10), + block_size=models.BlockSize(x=1, y=1, z=1), + ), + geoscience_object_id=GOOSE_UUID, + created_at=DATE, + created_by=MODEL_USER, + last_updated_at=DATE, + last_updated_by=MODEL_USER, + ) + + +def _mock_version( + version_id: int, version_uuid: uuid.UUID, goose_version_id: str, bbox=None, columns: Iterable[models.Column] = () +) -> models.Version: + return models.Version( + base_version_id=None if version_id == 1 else version_id - 1, + bbox=bbox, + bm_uuid=BM_UUID, + comment="", + created_at=DATE, + created_by=MODEL_USER, + geoscience_version_id=goose_version_id, + mapping=models.Mapping(columns=list(columns)), + parent_version_id=version_id - 1, + version_id=version_id, + version_uuid=version_uuid, + ) + + +FIRST_VERSION = _mock_version(1, uuid.uuid4(), "2") + +UPDATE_RESULT = models.UpdateWithUrl( + changes=models.UpdateDataLiteOutput( + columns=models.UpdateColumnsLiteOutput(new=[], update=[], rename=[], delete=[]) + ), + version_uuid=FIRST_VERSION.version_uuid, + job_uuid=uuid.uuid4(), + job_url=f"{BASE_URL}/jobs/{uuid.uuid4()}", + upload_url=f"{BASE_URL}/upload/{uuid.uuid4()}", +) + +SECOND_VERSION = _mock_version( + 2, + uuid.uuid4(), + "3", + models.BBox( + i_minmax=models.IntRange(min=1, max=3), + j_minmax=models.IntRange(min=4, max=6), + k_minmax=models.IntRange(min=7, max=9), + ), + columns=[ + models.Column(col_id=str(uuid.uuid4()), title="col1", data_type=models.DataType.Utf8), + models.Column(col_id=str(uuid.uuid4()), title="col2", data_type=models.DataType.Float64), + ], +) + + +class CreateTypedBlockModelRequestHandler(JobPollingRequestHandler): + def __init__( + self, + create_result: models.BlockModelAndJobURL, + job_response: JobResponse, + update_result: models.UpdateWithUrl | None = None, + update_job_response: JobResponse | None = None, + pending_request: int = 0, + ) -> None: + super().__init__(job_response, pending_request) + self._create_result = create_result + self._update_result = update_result + self._update_job_response = update_job_response + + async def request( + self, + method: RequestMethod, + url: str, + headers: HTTPHeaderDict | None = None, + post_params: list[tuple[str, str | bytes]] | None = None, + body: object | str | bytes | None = None, + request_timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> MockResponse: + match method: + case RequestMethod.POST if url.endswith("/block-models"): + return MockResponse(status_code=201, content=self._create_result.model_dump_json()) + case RequestMethod.POST if url.endswith("/uploaded"): + job_url, _ = url.rsplit("/", 1) + return MockResponse(status_code=201, content=json.dumps({"job_url": job_url})) + case RequestMethod.PATCH: + if self._update_result is None: + return self.not_found() + self._job_response = self._update_job_response + return MockResponse(status_code=202, content=self._update_result.model_dump_json()) + case RequestMethod.GET: + return self.job_poll() + case _: + return self.not_found() + + +class UpdateTypedBlockModelRequestHandler(JobPollingRequestHandler): + def __init__( + self, + update_result: models.UpdateWithUrl, + job_response: JobResponse, + pending_request: int = 0, + ) -> None: + super().__init__(job_response, pending_request) + self._update_result = update_result + + async def request( + self, + method: RequestMethod, + url: str, + headers: HTTPHeaderDict | None = None, + post_params: list[tuple[str, str | bytes]] | None = None, + body: object | str | bytes | None = None, + request_timeout: int | float | tuple[int | float, int | float] | None = None, + ) -> MockResponse: + match method: + case RequestMethod.POST if url.endswith("/uploaded"): + job_url, _ = url.rsplit("/", 1) + return MockResponse(status_code=201, content=json.dumps({"job_url": job_url})) + case RequestMethod.PATCH: + return MockResponse(status_code=202, content=self._update_result.model_dump_json()) + case RequestMethod.GET: + return self.job_poll() + case _: + return self.not_found() + + +class TestRegularBlockModelCreate(TestWithConnector, TestWithStorage): + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + @property + def base_path(self) -> str: + return f"blockmodel/orgs/{self.environment.org_id}/workspaces/{self.environment.workspace_id}" + + async def test_create_without_data(self) -> None: + """Test creating a block model without initial data.""" + self.transport.set_request_handler( + CreateTypedBlockModelRequestHandler( + create_result=_mock_create_result(self.environment), + job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=FIRST_VERSION, + ), + ) + ) + + data = RegularBlockModelData( + name="Test BM", + description="Test Block Model", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + rotations=[(RotationAxis.x, 20)], + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + ) + + block_model = await RegularBlockModel.create(self.context, data, path="test/path") + + self.assertEqual(block_model.id, BM_UUID) + self.assertEqual(block_model.name, "Test BM") + self.assertEqual(block_model.description, "Test Block Model") + self.assertEqual(block_model.origin, Point3(0, 0, 0)) + self.assertEqual(block_model.n_blocks, Size3i(10, 10, 10)) + self.assertEqual(block_model.block_size, Size3d(1.0, 1.0, 1.0)) + self.assertEqual(block_model.rotations, [(RotationAxis.x, 20)]) + self.assertTrue(block_model.cell_data.empty) + + async def test_create_with_data(self) -> None: + """Test creating a block model with initial cell data.""" + self.transport.set_request_handler( + CreateTypedBlockModelRequestHandler( + create_result=_mock_create_result(self.environment), + job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=FIRST_VERSION, + ), + update_result=UPDATE_RESULT, + update_job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=SECOND_VERSION, + ), + ) + ) + + cell_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + "col1": ["A", "B", "B"], + "col2": [4.5, 5.3, 6.2], + } + ) + + data = RegularBlockModelData( + name="Test BM", + description="Test Block Model", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + rotations=[(RotationAxis.x, 20)], + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + cell_data=cell_data, + units={"col2": "g/t"}, + ) + + with mock.patch("evo.common.io.upload.StorageDestination") as mock_destination: + mock_destination.upload_file = mock.AsyncMock() + block_model = await RegularBlockModel.create(self.context, data, path="test/path") + mock_destination.upload_file.assert_called_once() + + self.assertEqual(block_model.id, BM_UUID) + self.assertEqual(block_model.name, "Test BM") + self.assertEqual(block_model.version.version_id, 2) + self.assertEqual(len(block_model.cell_data), 3) + self.assertIn("col1", block_model.cell_data.columns) + self.assertIn("col2", block_model.cell_data.columns) + + +class TestRegularBlockModelGet(TestWithConnector, TestWithStorage): + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_get_block_model(self) -> None: + """Test retrieving an existing block model.""" + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition + + test_df = pd.DataFrame( + { + "x": [0.5, 1.5, 2.5], + "y": [0.5, 1.5, 2.5], + "z": [0.5, 1.5, 2.5], + "col1": ["A", "B", "C"], + } + ) + test_table = pyarrow.Table.from_pandas(test_df) + + with ( + mock.patch.object(BlockModelAPIClient, "get_block_model") as mock_get_bm, + mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query, + mock.patch.object(BlockModelAPIClient, "list_versions") as mock_list_versions, + ): + # Setup mock return values + mock_metadata = BlockModelData( + environment=self.environment, + id=BM_UUID, + name="Test BM", + description="Test Block Model", + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[(RotationAxis.x, 20)], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + mock_get_bm.return_value = mock_metadata + mock_query.return_value = test_table + mock_list_versions.return_value = [ + self._create_version(1, FIRST_VERSION.version_uuid), + ] + + block_model = await RegularBlockModel.get(self.context, BM_UUID) + + self.assertEqual(block_model.id, BM_UUID) + self.assertEqual(block_model.name, "Test BM") + self.assertEqual(block_model.origin, Point3(0, 0, 0)) + self.assertEqual(block_model.n_blocks, Size3i(10, 10, 10)) + self.assertEqual(block_model.block_size, Size3d(1.0, 1.0, 1.0)) + self.assertEqual(len(block_model.cell_data), 3) + + def _create_version(self, version_id: int, version_uuid: uuid.UUID): + """Helper to create a Version object for testing.""" + from evo.blockmodels.data import Version + + return Version( + bm_uuid=BM_UUID, + version_id=version_id, + version_uuid=version_uuid, + created_at=DATE, + created_by=USER, + comment="", + bbox=None, + base_version_id=None if version_id == 1 else version_id - 1, + parent_version_id=version_id - 1, + columns=[], + geoscience_version_id=str(version_id + 1), + ) + + async def test_get_block_model_with_version(self) -> None: + """Test retrieving a specific version of a block model.""" + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition + + version_uuid = uuid.uuid4() + + test_df = pd.DataFrame( + { + "x": [0.5, 1.5, 2.5], + "y": [0.5, 1.5, 2.5], + "z": [0.5, 1.5, 2.5], + "col1": ["A", "B", "C"], + } + ) + test_table = pyarrow.Table.from_pandas(test_df) + + with ( + mock.patch.object(BlockModelAPIClient, "get_block_model") as mock_get_bm, + mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query, + mock.patch.object(BlockModelAPIClient, "list_versions") as mock_list_versions, + ): + mock_metadata = BlockModelData( + environment=self.environment, + id=BM_UUID, + name="Test BM", + description="Test Block Model", + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[(RotationAxis.x, 20)], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + mock_get_bm.return_value = mock_metadata + mock_query.return_value = test_table + mock_list_versions.return_value = [ + self._create_version(2, version_uuid), + self._create_version(1, FIRST_VERSION.version_uuid), + ] + + block_model = await RegularBlockModel.get(self.context, BM_UUID, version_id=version_uuid) + + self.assertEqual(block_model.id, BM_UUID) + self.assertEqual(block_model.version.version_uuid, version_uuid) + + +class TestRegularBlockModelUpdateAttributes(TestWithConnector, TestWithStorage): + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_update_attributes(self) -> None: + """Test updating attributes on an existing block model.""" + self.transport.set_request_handler( + UpdateTypedBlockModelRequestHandler( + update_result=UPDATE_RESULT, + job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=SECOND_VERSION, + ), + ) + ) + + # Create a mock RegularBlockModel instance + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition, Version + + client = BlockModelAPIClient.from_context(self.context) + metadata = BlockModelData( + environment=self.environment, + id=BM_UUID, + name="Test BM", + description="Test Block Model", + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[(RotationAxis.x, 20)], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + version = Version( + bm_uuid=BM_UUID, + version_id=1, + version_uuid=FIRST_VERSION.version_uuid, + created_at=DATE, + created_by=USER, + comment="", + bbox=None, + base_version_id=None, + parent_version_id=0, + columns=[], + geoscience_version_id="2", + ) + cell_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + } + ) + + block_model = RegularBlockModel( + client=client, + metadata=metadata, + version=version, + cell_data=cell_data, + ) + + # Update with new columns + new_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + "col1": ["A", "B", "B"], + "col2": [4.5, 5.3, 6.2], + } + ) + + with mock.patch("evo.common.io.upload.StorageDestination") as mock_destination: + mock_destination.upload_file = mock.AsyncMock() + new_version = await block_model.update_attributes( + new_data, + new_columns=["col1", "col2"], + units={"col2": "g/t"}, + ) + mock_destination.upload_file.assert_called_once() + + self.assertEqual(new_version.version_id, 2) + self.assertEqual(block_model.version.version_id, 2) + self.assertIn("col1", block_model.cell_data.columns) + self.assertIn("col2", block_model.cell_data.columns) + + +class TestTypedTypes(TestWithConnector): + """Test the typed type classes.""" + + def test_point3(self) -> None: + """Test Point3 named tuple.""" + p = Point3(1.0, 2.0, 3.0) + self.assertEqual(p.x, 1.0) + self.assertEqual(p.y, 2.0) + self.assertEqual(p.z, 3.0) + + def test_size3i(self) -> None: + """Test Size3i named tuple.""" + s = Size3i(10, 20, 30) + self.assertEqual(s.nx, 10) + self.assertEqual(s.ny, 20) + self.assertEqual(s.nz, 30) + self.assertEqual(s.total_size, 6000) + + def test_size3d(self) -> None: + """Test Size3d named tuple.""" + s = Size3d(1.5, 2.5, 3.5) + self.assertEqual(s.dx, 1.5) + self.assertEqual(s.dy, 2.5) + self.assertEqual(s.dz, 3.5) + + def test_bounding_box_from_origin_and_size(self) -> None: + """Test BoundingBox.from_origin_and_size class method.""" + from evo.blockmodels.typed import BoundingBox + + bbox = BoundingBox.from_origin_and_size( + origin=Point3(0, 0, 0), + size=Size3i(10, 20, 30), + cell_size=Size3d(1.0, 2.0, 3.0), + ) + self.assertEqual(bbox.x_min, 0) + self.assertEqual(bbox.x_max, 10) + self.assertEqual(bbox.y_min, 0) + self.assertEqual(bbox.y_max, 40) + self.assertEqual(bbox.z_min, 0) + self.assertEqual(bbox.z_max, 90) + + +def _make_block_model_instance(context, client): + """Helper to create a RegularBlockModel instance for testing base class methods.""" + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition, Version + + environment = context.get_environment() + metadata = BlockModelData( + environment=environment, + id=BM_UUID, + name="Test BM", + description="Test Block Model", + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[(RotationAxis.x, 20)], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system="EPSG:4326", + size_unit_id="m", + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + version = Version( + bm_uuid=BM_UUID, + version_id=1, + version_uuid=FIRST_VERSION.version_uuid, + created_at=DATE, + created_by=USER, + comment="", + bbox=None, + base_version_id=None, + parent_version_id=0, + columns=[ + 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), + ], + geoscience_version_id="2", + ) + cell_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + "Au": [1.5, 2.3, 3.1], + "density": [2.7, 2.8, 2.6], + } + ) + + return RegularBlockModel( + client=client, + metadata=metadata, + version=version, + cell_data=cell_data, + context=context, + ) + + +class TestBaseTypedBlockModelToDataframe(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel.to_dataframe method inherited by RegularBlockModel.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_to_dataframe_default(self) -> None: + """Test to_dataframe with default parameters (latest version, all columns).""" + from evo.blockmodels import BlockModelAPIClient + + test_df = pd.DataFrame({"x": [0.5, 1.5], "y": [0.5, 1.5], "z": [0.5, 1.5], "Au": [1.5, 2.3]}) + test_table = pyarrow.Table.from_pandas(test_df) + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + with mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query: + mock_query.return_value = test_table + df = await block_model.to_dataframe() + + mock_query.assert_called_once() + call_kwargs = mock_query.call_args + self.assertEqual(call_kwargs.kwargs["bm_id"], BM_UUID) + self.assertEqual(call_kwargs.kwargs["columns"], ["*"]) + self.assertIsNone(call_kwargs.kwargs["version_uuid"]) + self.assertEqual(len(df), 2) + self.assertIn("Au", df.columns) + + async def test_to_dataframe_specific_version(self) -> None: + """Test to_dataframe with a specific version UUID.""" + from evo.blockmodels import BlockModelAPIClient + + specific_version = uuid.uuid4() + test_df = pd.DataFrame({"x": [0.5], "y": [0.5], "z": [0.5], "Au": [1.5]}) + test_table = pyarrow.Table.from_pandas(test_df) + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + with mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query: + mock_query.return_value = test_table + await block_model.to_dataframe(version_uuid=specific_version) + + call_kwargs = mock_query.call_args + self.assertEqual(call_kwargs.kwargs["version_uuid"], specific_version) + + async def test_to_dataframe_selected_columns(self) -> None: + """Test to_dataframe with specific columns.""" + from evo.blockmodels import BlockModelAPIClient + + test_df = pd.DataFrame({"x": [0.5], "y": [0.5], "z": [0.5], "Au": [1.5]}) + test_table = pyarrow.Table.from_pandas(test_df) + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + with mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query: + mock_query.return_value = test_table + await block_model.to_dataframe(columns=["Au"]) + + call_kwargs = mock_query.call_args + self.assertEqual(call_kwargs.kwargs["columns"], ["Au"]) + + +class TestBaseTypedBlockModelAddAttribute(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel.add_attribute method.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_add_attribute(self) -> None: + """Test adding a new attribute to a block model.""" + self.transport.set_request_handler( + UpdateTypedBlockModelRequestHandler( + update_result=UPDATE_RESULT, + job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=SECOND_VERSION, + ), + ) + ) + + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + new_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + "Cu": [0.5, 0.7, 0.3], + } + ) + + with mock.patch("evo.common.io.upload.StorageDestination") as mock_destination: + mock_destination.upload_file = mock.AsyncMock() + version = await block_model.add_attribute(new_data, "Cu", unit="pct") + mock_destination.upload_file.assert_called_once() + + self.assertEqual(version.version_id, 2) + + async def test_add_attribute_without_unit(self) -> None: + """Test adding a new attribute without a unit.""" + self.transport.set_request_handler( + UpdateTypedBlockModelRequestHandler( + update_result=UPDATE_RESULT, + job_response=JobResponse( + job_status=JobStatus.COMPLETE, + payload=SECOND_VERSION, + ), + ) + ) + + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + new_data = pd.DataFrame( + { + "i": [1, 2, 3], + "j": [4, 5, 6], + "k": [7, 8, 9], + "category": ["A", "B", "C"], + } + ) + + with mock.patch("evo.common.io.upload.StorageDestination") as mock_destination: + mock_destination.upload_file = mock.AsyncMock() + version = await block_model.add_attribute(new_data, "category") + mock_destination.upload_file.assert_called_once() + + self.assertEqual(version.version_id, 2) + + +class TestBaseTypedBlockModelSetAttributeUnits(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel.set_attribute_units method.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_set_attribute_units(self) -> None: + """Test setting units for attributes on a block model.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + mock_version = _mock_version(2, uuid.uuid4(), "3") + + with mock.patch.object(BlockModelAPIClient, "update_column_metadata") as mock_update: + mock_update.return_value = mock_version + version = await block_model.set_attribute_units({"Au": "g/t", "density": "t/m3"}) + + mock_update.assert_called_once_with( + bm_id=BM_UUID, + column_updates={"Au": "g/t", "density": "t/m3"}, + ) + self.assertEqual(version.version_id, 2) + # Internal version should be updated + self.assertEqual(block_model.version.version_id, 2) + + +class TestBaseTypedBlockModelVersionsAndMetadata(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel.get_versions and get_block_model_metadata methods.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_get_versions(self) -> None: + """Test retrieving all versions of a block model.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + v1 = _mock_version(1, uuid.uuid4(), "2") + v2 = _mock_version(2, uuid.uuid4(), "3") + + with mock.patch.object(BlockModelAPIClient, "list_versions") as mock_list: + mock_list.return_value = [v2, v1] + versions = await block_model.get_versions() + + mock_list.assert_called_once_with(BM_UUID) + self.assertEqual(len(versions), 2) + self.assertEqual(versions[0].version_id, 2) + self.assertEqual(versions[1].version_id, 1) + + async def test_get_block_model_metadata(self) -> None: + """Test retrieving full block model metadata.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + mock_metadata = _mock_block_model(self.environment) + + with mock.patch.object(BlockModelAPIClient, "get_block_model") as mock_get: + mock_get.return_value = client._bm_from_model(mock_metadata) + metadata = await block_model.get_block_model_metadata() + + mock_get.assert_called_once_with(BM_UUID) + self.assertEqual(metadata.id, BM_UUID) + self.assertEqual(metadata.name, "Test BM") + + +class TestBaseTypedBlockModelColumnIdMap(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel._get_column_id_map method.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + def test_get_column_id_map(self) -> None: + """Test that _get_column_id_map correctly maps column names to UUIDs.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + col_id_map = block_model._get_column_id_map() + + self.assertIn("Au", col_id_map) + self.assertIn("density", col_id_map) + self.assertEqual(len(col_id_map), 2) + + def test_get_column_id_map_with_invalid_uuid(self) -> None: + """Test that _get_column_id_map skips columns with invalid UUIDs.""" + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition, Version + + client = BlockModelAPIClient.from_context(self.context) + + version = Version( + bm_uuid=BM_UUID, + version_id=1, + version_uuid=FIRST_VERSION.version_uuid, + created_at=DATE, + created_by=USER, + comment="", + bbox=None, + base_version_id=None, + parent_version_id=0, + columns=[ + 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), + ], + geoscience_version_id="2", + ) + + metadata = BlockModelData( + environment=self.environment, + id=BM_UUID, + name="Test BM", + description=None, + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system=None, + size_unit_id=None, + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + + block_model = RegularBlockModel( + client=client, + metadata=metadata, + version=version, + cell_data=pd.DataFrame(), + context=self.context, + ) + + col_id_map = block_model._get_column_id_map() + + # "i" column has non-UUID col_id, should be skipped + self.assertNotIn("i_idx", col_id_map) + self.assertIn("Au", col_id_map) + self.assertEqual(len(col_id_map), 1) + + +class TestBaseTypedBlockModelRefresh(TestWithConnector, TestWithStorage): + """Tests for BaseTypedBlockModel.refresh method.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + async def test_refresh(self) -> None: + """Test refreshing a block model updates metadata, data, and version.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + refreshed_df = pd.DataFrame( + {"x": [0.5, 1.5, 2.5], "y": [0.5, 1.5, 2.5], "z": [0.5, 1.5, 2.5], "Au": [5.0, 6.0, 7.0]} + ) + refreshed_table = pyarrow.Table.from_pandas(refreshed_df) + new_version = _mock_version(2, uuid.uuid4(), "3") + + mock_bm = _mock_block_model(self.environment) + + with ( + mock.patch.object(BlockModelAPIClient, "get_block_model") as mock_get, + mock.patch.object(BlockModelAPIClient, "query_block_model_as_table") as mock_query, + mock.patch.object(BlockModelAPIClient, "list_versions") as mock_list, + ): + mock_get.return_value = client._bm_from_model(mock_bm) + mock_query.return_value = refreshed_table + mock_list.return_value = [new_version] + + await block_model.refresh() + + self.assertEqual(block_model.version.version_id, 2) + self.assertEqual(len(block_model.cell_data), 3) + self.assertIn("Au", block_model.cell_data.columns) + + +class TestBaseTypedBlockModelGetContext(TestWithConnector, TestWithStorage): + """Tests for RegularBlockModel._get_context method.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + self.setup_universal_headers(get_header_metadata("evo.blockmodels.client")) + self._context = StaticContext.from_environment( + environment=self.environment, + connector=self.connector, + cache=self.cache, + ) + + @property + def context(self): + return self._context + + def test_get_context_returns_provided_context(self) -> None: + """Test _get_context returns the context provided at construction.""" + from evo.blockmodels import BlockModelAPIClient + + client = BlockModelAPIClient.from_context(self.context) + block_model = _make_block_model_instance(self.context, client) + + ctx = block_model._get_context() + self.assertEqual(ctx.get_environment(), self.context.get_environment()) + + def test_get_context_builds_from_client_when_none(self) -> None: + """Test _get_context builds a StaticContext from client when no context provided.""" + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels.data import BlockModel as BlockModelData + from evo.blockmodels.data import RegularGridDefinition, Version + + client = BlockModelAPIClient.from_context(self.context) + + metadata = BlockModelData( + environment=self.environment, + id=BM_UUID, + name="Test BM", + description=None, + created_at=DATE, + created_by=USER, + grid_definition=RegularGridDefinition( + model_origin=[0, 0, 0], + rotations=[], + n_blocks=[10, 10, 10], + block_size=[1.0, 1.0, 1.0], + ), + coordinate_reference_system=None, + size_unit_id=None, + bbox=BM_BBOX, + last_updated_at=DATE, + last_updated_by=USER, + geoscience_object_id=GOOSE_UUID, + ) + version = Version( + bm_uuid=BM_UUID, + version_id=1, + version_uuid=FIRST_VERSION.version_uuid, + created_at=DATE, + created_by=USER, + comment="", + bbox=None, + base_version_id=None, + parent_version_id=0, + columns=[], + geoscience_version_id="2", + ) + + block_model = RegularBlockModel( + client=client, + metadata=metadata, + version=version, + cell_data=pd.DataFrame(), + context=None, # No context provided + ) + + ctx = block_model._get_context() + self.assertIsNotNone(ctx) + self.assertEqual(ctx.get_environment(), self.environment) diff --git a/packages/evo-blockmodels/tests/test_typed_report.py b/packages/evo-blockmodels/tests/test_typed_report.py new file mode 100644 index 00000000..acdc7bd5 --- /dev/null +++ b/packages/evo-blockmodels/tests/test_typed_report.py @@ -0,0 +1,244 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid +from datetime import datetime + +from evo.blockmodels.endpoints import models +from evo.blockmodels.typed import ( + Aggregation, + MassUnits, + Report, + ReportCategorySpec, + ReportColumnSpec, + ReportResult, + ReportSpecificationData, +) +from evo.common import StaticContext +from evo.common.test_tools import TestWithConnector, TestWithStorage + +BM_UUID = uuid.uuid4() +RS_UUID = uuid.uuid4() +RESULT_UUID = uuid.uuid4() +VERSION_UUID = uuid.uuid4() +COL_UUID = uuid.uuid4() +CAT_COL_UUID = uuid.uuid4() +DATE = datetime(2026, 1, 1) + + +class TestReportSpecificationData(TestWithConnector): + """Tests for ReportSpecificationData dataclass.""" + + def test_basic_creation(self) -> None: + """Test creating a basic report specification.""" + data = ReportSpecificationData( + name="Test Report", + columns=[ + ReportColumnSpec(column_name="Au", aggregation="MASS_AVERAGE", output_unit_id="g/t"), + ], + mass_unit_id="t", + ) + self.assertEqual(data.name, "Test Report") + self.assertEqual(len(data.columns), 1) + self.assertEqual(data.columns[0].column_name, "Au") + self.assertEqual(data.mass_unit_id, "t") + self.assertTrue(data.autorun) + self.assertTrue(data.run_now) + + def test_with_categories(self) -> None: + """Test creating a report specification with categories.""" + data = ReportSpecificationData( + name="Test Report", + columns=[ + ReportColumnSpec(column_name="Au", output_unit_id="g/t"), + ], + categories=[ + ReportCategorySpec(column_name="domain", label="Domain", values=["LMS1", "LMS2"]), + ], + mass_unit_id="t", + density_value=2.7, + density_unit_id="t/m3", + ) + self.assertEqual(len(data.categories), 1) + self.assertEqual(data.categories[0].column_name, "domain") + self.assertEqual(data.categories[0].values, ["LMS1", "LMS2"]) + self.assertEqual(data.density_value, 2.7) + + def test_column_spec_default_label(self) -> None: + """Test that column spec label defaults to column name.""" + spec = ReportColumnSpec(column_name="Au") + self.assertEqual(spec._get_label(), "Au") + + spec_with_label = ReportColumnSpec(column_name="Au", label="Gold Grade") + self.assertEqual(spec_with_label._get_label(), "Gold Grade") + + +class TestReportResult(TestWithConnector): + """Tests for ReportResult class.""" + + def test_to_dataframe(self) -> None: + """Test converting report result to DataFrame.""" + result = ReportResult( + result_uuid=RESULT_UUID, + report_specification_uuid=RS_UUID, + block_model_uuid=BM_UUID, + version_id=1, + version_uuid=VERSION_UUID, + created_at=DATE, + categories=[{"label": "Domain", "col_id": str(CAT_COL_UUID)}], + columns=[{"label": "Au Grade", "unit_id": "g/t"}], + result_sets=[ + { + "cutoff_value": 0.5, + "rows": [ + {"categories": ["LMS1"], "values": [2.5]}, + {"categories": ["LMS2"], "values": [3.2]}, + ], + }, + ], + ) + + df = result.to_dataframe() + self.assertEqual(len(df), 2) + self.assertIn("cutoff", df.columns) + self.assertIn("Domain", df.columns) + self.assertIn("Au Grade", df.columns) + self.assertEqual(df.iloc[0]["Domain"], "LMS1") + self.assertEqual(df.iloc[0]["Au Grade"], 2.5) + + def test_repr(self) -> None: + """Test string representation of report result.""" + result = ReportResult( + result_uuid=RESULT_UUID, + report_specification_uuid=RS_UUID, + block_model_uuid=BM_UUID, + version_id=1, + version_uuid=VERSION_UUID, + created_at=DATE, + categories=[{"label": "Domain", "col_id": str(CAT_COL_UUID)}], + columns=[{"label": "Au Grade", "unit_id": "g/t"}], + result_sets=[{"cutoff_value": None, "rows": [{"categories": ["LMS1"], "values": [2.5]}]}], + ) + + repr_str = repr(result) + self.assertIn("ReportResult", repr_str) + self.assertIn("version=1", repr_str) + self.assertIn("rows=1", repr_str) + + +class TestReport(TestWithConnector, TestWithStorage): + """Tests for Report class.""" + + def setUp(self) -> None: + TestWithConnector.setUp(self) + TestWithStorage.setUp(self) + + def _mock_specification(self) -> models.ReportSpecificationWithLastRunInfo: + return models.ReportSpecificationWithLastRunInfo( + report_specification_uuid=RS_UUID, + bm_uuid=BM_UUID, + name="Test Report", + description="A test report", + revision=1, + autorun=True, + mass_unit_id="t", + columns=[ + models.ReportColumn( + col_id=COL_UUID, + label="Au Grade", + aggregation=models.ReportAggregation.MASS_AVERAGE, + output_unit_id="g/t", + ) + ], + categories=[ + models.ReportCategory( + col_id=CAT_COL_UUID, + label="Domain", + values=["LMS1", "LMS2", "LMS3"], + ) + ], + ) + + def test_report_properties(self) -> None: + """Test Report properties.""" + spec = self._mock_specification() + context = StaticContext.from_environment(self.environment, self.connector, self.cache) + report = Report(context, BM_UUID, spec) + + self.assertEqual(report.id, RS_UUID) + self.assertEqual(report.name, "Test Report") + self.assertEqual(report.description, "A test report") + self.assertEqual(report.block_model_uuid, BM_UUID) + self.assertEqual(report.revision, 1) + + +class TestReportColumnSpec(TestWithConnector): + """Tests for ReportColumnSpec dataclass.""" + + def test_defaults(self) -> None: + """Test default values.""" + spec = ReportColumnSpec(column_name="Au") + self.assertEqual(spec.aggregation, "SUM") + self.assertIsNone(spec.label) + self.assertIsNone(spec.output_unit_id) + + def test_custom_values(self) -> None: + """Test custom values.""" + spec = ReportColumnSpec( + column_name="Au", + aggregation=Aggregation.MASS_AVERAGE, + label="Gold Grade", + output_unit_id="g/t", + ) + self.assertEqual(spec.aggregation, Aggregation.MASS_AVERAGE) + self.assertEqual(spec.label, "Gold Grade") + self.assertEqual(spec.output_unit_id, "g/t") + + +class TestReportCategorySpec(TestWithConnector): + """Tests for ReportCategorySpec dataclass.""" + + def test_defaults(self) -> None: + """Test default values.""" + spec = ReportCategorySpec(column_name="domain") + self.assertIsNone(spec.label) + self.assertIsNone(spec.values) + + def test_with_values(self) -> None: + """Test with explicit values.""" + spec = ReportCategorySpec( + column_name="domain", + label="Domain", + values=["LMS1", "LMS2", "LMS3"], + ) + self.assertEqual(spec.label, "Domain") + self.assertEqual(spec.values, ["LMS1", "LMS2", "LMS3"]) + + +class TestMassUnits(TestWithConnector): + """Tests for MassUnits helper class.""" + + def test_mass_unit_constants(self) -> None: + """Test that MassUnits provides expected constants.""" + self.assertEqual(MassUnits.TONNES, "t") + self.assertEqual(MassUnits.KILOGRAMS, "kg") + self.assertEqual(MassUnits.GRAMS, "g") + self.assertEqual(MassUnits.OUNCES, "oz") + self.assertEqual(MassUnits.POUNDS, "lb") + + def test_use_in_report_spec(self) -> None: + """Test using MassUnits in ReportSpecificationData.""" + data = ReportSpecificationData( + name="Test Report", + columns=[ReportColumnSpec(column_name="Au")], + mass_unit_id=MassUnits.TONNES, + ) + self.assertEqual(data.mass_unit_id, "t") diff --git a/packages/evo-objects/docs/examples/typed-objects-blockmodel.ipynb b/packages/evo-objects/docs/examples/typed-objects-blockmodel.ipynb new file mode 100644 index 00000000..1128ef0d --- /dev/null +++ b/packages/evo-objects/docs/examples/typed-objects-blockmodel.ipynb @@ -0,0 +1,541 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Typed Objects" + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": {}, + "source": [ + "In addition to the interfaces offered by `ObjectAPIClient` and `DownloadedObject`, which provides access to Geoscience Objects in a way that is agnostic to the specific object type, the `evo-objects` package also provides a set of \"typed objects\" that represent specific object types.\n" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "As usual, we need to first authenticate, which we can do using the `ServiceManagerWidget` from the `evo-notebooks` package" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.notebooks import ServiceManagerWidget\n", + "\n", + "manager = await ServiceManagerWidget.with_auth_code(\n", + " client_id=\"your-client-id\", cache_location=\"./notebook-data\"\n", + ").login()" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Working with Block Models" + ] + }, + { + "cell_type": "markdown", + "id": "5", + "metadata": {}, + "source": [ + "Block models are stored in the Block Model Service, which is separate from the Geoscience Object Service. The `BlockModel` class provides a unified interface that handles both services - it creates the block model in the Block Model Service and automatically creates a corresponding Geoscience Object reference." + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "### Creating a Regular Block Model" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, + "source": [ + "To create a new block model, use `BlockModel.create_regular()` with a `RegularBlockModelData` object:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from evo.blockmodels import Units\n", + "from evo.objects.typed import BlockModel, Point3, RegularBlockModelData, Size3d, Size3i\n", + "\n", + "# Create sample block model data\n", + "# Define the grid parameters\n", + "origin = (0, 0, 0)\n", + "n_blocks = (10, 10, 5)\n", + "block_size = (2.5, 5.0, 5.0)\n", + "total_blocks = n_blocks[0] * n_blocks[1] * n_blocks[2]\n", + "\n", + "# Generate block centroid coordinates (x, y, z)\n", + "# Centroids are at the center of each block\n", + "centroids = []\n", + "for k in range(n_blocks[2]):\n", + " for j in range(n_blocks[1]):\n", + " for i in range(n_blocks[0]):\n", + " # Calculate centroid position: origin + (index + 0.5) * block_size\n", + " x = origin[0] + (i + 0.5) * block_size[0]\n", + " y = origin[1] + (j + 0.5) * block_size[1]\n", + " z = origin[2] + (k + 0.5) * block_size[2]\n", + " centroids.append((x, y, z))\n", + "\n", + "# Create DataFrame with x, y, z coordinates (more user-friendly than i, j, k)\n", + "block_data = pd.DataFrame(\n", + " {\n", + " \"x\": [c[0] for c in centroids],\n", + " \"y\": [c[1] for c in centroids],\n", + " \"z\": [c[2] for c in centroids],\n", + " \"grade\": np.random.rand(total_blocks) * 10, # Random grade values 0-10\n", + " \"density\": np.random.rand(total_blocks) * 2 + 2, # Random density 2-4\n", + " }\n", + ")\n", + "\n", + "# Create the block model using BlockModel.create_regular()\n", + "# Use the Units class for valid unit IDs\n", + "bm_data = RegularBlockModelData(\n", + " name=\"Example Block Model 26\",\n", + " description=\"A sample block model created from the notebook\",\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", + " block_size=Size3d(dx=block_size[0], dy=block_size[1], dz=block_size[2]),\n", + " cell_data=block_data,\n", + " crs=\"EPSG:28354\",\n", + " size_unit_id=Units.METRES,\n", + " units={\"grade\": Units.GRAMS_PER_TONNE, \"density\": Units.TONNES_PER_CUBIC_METRE},\n", + ")\n", + "\n", + "# Create the block model - this handles both Block Model Service and Geoscience Object creation\n", + "block_model = await BlockModel.create_regular(manager, bm_data)\n", + "\n", + "print(f\"Created block model: {block_model.name}\")\n", + "print(f\"Block Model UUID: {block_model.block_model_uuid}\")\n", + "print(f\"Geometry type: {block_model.geometry.model_type}\")\n", + "print(f\"Origin: {block_model.geometry.origin}\")\n", + "print(f\"N blocks: {block_model.geometry.n_blocks}\")\n", + "print(f\"Block size: {block_model.geometry.block_size}\")\n", + "print(f\"Attributes: {[attr.name for attr in block_model.attributes]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### Loading an Existing Block Model" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "You can load an existing `BlockModel` using `from_reference`, just like other typed objects:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the block model we just created using its URL\n", + "loaded_bm = await BlockModel.from_reference(manager, block_model.metadata.url)\n", + "\n", + "print(f\"Loaded BlockModel: {loaded_bm.name}\")\n", + "print(f\"Block Model UUID: {loaded_bm.block_model_uuid}\")\n", + "print(f\"Bounding Box: {loaded_bm.bounding_box}\")" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "### Accessing Block Model Data" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "The `BlockModel` provides methods to access the actual block data from the Block Model Service:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "# Get all block data as a DataFrame\n", + "df = await block_model.get_data(columns=[\"*\"])\n", + "print(f\"Retrieved {len(df)} blocks\")\n", + "df.head(10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "# You can also query specific columns\n", + "grade_data = await block_model.get_data(columns=[\"grade\"])\n", + "print(\"Grade statistics:\")\n", + "print(grade_data[\"grade\"].describe())" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "### Adding New Attributes to a Block Model" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "You can add new attributes to the block model. This creates a new version in the Block Model Service:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "# Calculate a new attribute based on existing data\n", + "# Get current data including coordinates\n", + "df = await block_model.get_data(columns=[\"x\", \"y\", \"z\", \"grade\", \"density\"])\n", + "\n", + "# Create new attribute DataFrame with x, y, z coordinates\n", + "df_with_new_attr = pd.DataFrame(\n", + " {\n", + " \"x\": df[\"x\"],\n", + " \"y\": df[\"y\"],\n", + " \"z\": df[\"z\"],\n", + " \"metal_content\": df[\"grade\"] * df[\"density\"], # grade * density\n", + " }\n", + ")\n", + "\n", + "# Add the new attribute to the block model\n", + "# Use Units class for valid unit IDs\n", + "new_version = await block_model.add_attribute(\n", + " df_with_new_attr,\n", + " attribute_name=\"metal_content\",\n", + " unit=Units.KG_PER_CUBIC_METRE,\n", + ")\n", + "\n", + "print(f\"Added attribute 'metal_content', new version: {new_version.version_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "new_version" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "### Updating Multiple Attributes" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "For more complex updates (adding multiple columns, updating existing columns, or deleting columns), use `update_attributes`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "# Create data with multiple new attributes using x, y, z coordinates\n", + "df_updates = pd.DataFrame(\n", + " {\n", + " \"x\": df[\"x\"],\n", + " \"y\": df[\"y\"],\n", + " \"z\": df[\"z\"],\n", + " \"classification\": np.where(df[\"grade\"] > 5, \"high\", \"low\"),\n", + " \"value_index\": df[\"grade\"] * df[\"density\"] * 100,\n", + " }\n", + ")\n", + "\n", + "# Add multiple new columns at once\n", + "version = await block_model.update_attributes(\n", + " df_updates,\n", + " new_columns=[\"classification\", \"value_index\"],\n", + ")\n", + "\n", + "print(f\"Updated block model, new version: {version.version_id}\")\n", + "\n", + "# Verify the new attributes\n", + "updated_df = await block_model.get_data(columns=[\"*\"])\n", + "print(f\"Columns now available: {list(updated_df.columns)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "23", + "metadata": {}, + "source": [ + "## Block Model Reports\n", + "\n", + "Reports provide resource estimation summaries for block models. They calculate tonnages, grades, and metal content grouped by categories (e.g., geological domains).\n", + "\n", + "**Requirements for reports:**\n", + "1. Columns must have units defined\n", + "2. At least one category column for grouping results\n", + "3. Density information (column or fixed value)\n", + "\n", + "**Key classes:**\n", + "- `ReportColumnSpec` - Define which columns to report and how to aggregate them\n", + "- `ReportCategorySpec` - Define category columns for grouping\n", + "- `ReportSpecificationData` - The full report definition\n", + "- `MassUnits` - Helper with common mass unit IDs" + ] + }, + { + "cell_type": "markdown", + "id": "24", + "metadata": {}, + "source": [ + "### Add a Domain Column\n", + "\n", + "First, let's add a category column for grouping. We'll create geological domains by slicing the block model into three zones based on elevation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "# Refresh to get latest data\n", + "block_model = await block_model.refresh()\n", + "df = await block_model.to_dataframe()\n", + "\n", + "# Create domain column based on z-coordinate (elevation)\n", + "# Divide into 3 domains: LMS1 (lower), LMS2 (middle), LMS3 (upper)\n", + "z_min, z_max = df[\"z\"].min(), df[\"z\"].max()\n", + "z_range = z_max - z_min\n", + "\n", + "\n", + "def assign_domain(z):\n", + " if z < z_min + z_range / 3:\n", + " return \"LMS1\" # Lower zone\n", + " elif z < z_min + 2 * z_range / 3:\n", + " return \"LMS2\" # Middle zone\n", + " else:\n", + " return \"LMS3\" # Upper zone\n", + "\n", + "\n", + "df[\"domain\"] = df[\"z\"].apply(assign_domain)\n", + "\n", + "# Add the domain column to the block model\n", + "domain_data = df[[\"x\", \"y\", \"z\", \"domain\"]]\n", + "version = await block_model.add_attribute(domain_data, \"domain\")\n", + "print(f\"Added domain column. New version: {version.version_id}\")\n", + "\n", + "# Check domain distribution\n", + "print(\"\\nDomain distribution:\")\n", + "print(df[\"domain\"].value_counts())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "# Refresh to see the new attribute\n", + "block_model = await block_model.refresh()\n", + "block_model.attributes" + ] + }, + { + "cell_type": "markdown", + "id": "27", + "metadata": {}, + "source": [ + "### Create and Run a Report\n", + "\n", + "Now we can create a report that calculates tonnages and grades by domain.\n", + "\n", + "**Aggregation options (`Aggregation` enum):**\n", + "- `Aggregation.MASS_AVERAGE` - Mass-weighted average, use for **grades** (e.g., Au g/t)\n", + "- `Aggregation.SUM` - Sum of values, use for **metal content** (e.g., Au kg)\n", + "\n", + "**Output unit options (`Units` class):**\n", + "- Grades: `Units.GRAMS_PER_TONNE`, `Units.PERCENT`, `Units.PPM`, `Units.OUNCES_PER_TONNE`\n", + "- Metal: `Units.KILOGRAMS`, `Units.TONNES`, `Units.GRAMS`, `Units.TROY_OUNCES`\n", + "\n", + "**Mass unit options (`MassUnits` class):**\n", + "- `MassUnits.TONNES` - Metric tonnes (\"t\")\n", + "- `MassUnits.KILOGRAMS` - Kilograms (\"kg\")\n", + "- `MassUnits.OUNCES` - Troy ounces (\"oz\")\n", + "\n", + "**Density options (choose ONE):**\n", + "- `density_column_name=\"density\"` - Use a column (don't set `density_unit_id`)\n", + "- `density_value=2.7, density_unit_id=\"t/m3\"` - Use fixed value (both required)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "from evo.blockmodels import Units\n", + "from evo.blockmodels.typed import (\n", + " Aggregation,\n", + " MassUnits,\n", + " ReportCategorySpec,\n", + " ReportColumnSpec,\n", + " ReportSpecificationData,\n", + ")\n", + "\n", + "# Define the report\n", + "report_data = ReportSpecificationData(\n", + " name=\"Grade Resource Report\",\n", + " description=\"Resource estimate by domain\",\n", + " columns=[\n", + " ReportColumnSpec(\n", + " column_name=\"grade\",\n", + " aggregation=Aggregation.MASS_AVERAGE, # Use MASS_AVERAGE for grades\n", + " label=\"Grade\",\n", + " output_unit_id=Units.GRAMS_PER_TONNE, # Use Units class for discoverability\n", + " ),\n", + " # You can add more columns:\n", + " # ReportColumnSpec(column_name=\"metal\", aggregation=Aggregation.SUM, label=\"Metal\", output_unit_id=Units.KILOGRAMS),\n", + " ],\n", + " categories=[\n", + " ReportCategorySpec(\n", + " column_name=\"domain\",\n", + " label=\"Domain\",\n", + " values=[\"LMS1\", \"LMS2\", \"LMS3\"], # Optional: limit to specific values\n", + " ),\n", + " ],\n", + " mass_unit_id=MassUnits.TONNES, # Output mass in tonnes\n", + " density_column_name=\"density\", # Use density column (unit comes from column)\n", + " run_now=True, # Run immediately\n", + ")\n", + "\n", + "# Create the report\n", + "report = await block_model.create_report(report_data)\n", + "print(f\"Created report: {report.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "# Pretty-print the report (shows BlockSync link)\n", + "report" + ] + }, + { + "cell_type": "markdown", + "id": "30", + "metadata": {}, + "source": [ + "### View Report Results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the latest report result (waits if report is still running)\n", + "result = await report.refresh()\n", + "\n", + "# Pretty-print the result (displays table in Jupyter)\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the BlockSync URL to view the report interactively\n", + "print(f\"View report in BlockSync: {report.blocksync_url}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/packages/evo-objects/pyproject.toml b/packages/evo-objects/pyproject.toml index 0b121de1..9ae1e7e2 100644 --- a/packages/evo-objects/pyproject.toml +++ b/packages/evo-objects/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "evo-objects" description = "Python SDK for using the Seequent Evo Geoscience Object API" -version = "0.3.3" +version = "0.4.0" requires-python = ">=3.10" license-files = ["LICENSE.md"] dynamic = ["readme"] @@ -24,11 +24,12 @@ Documentation = "https://developer.seequent.com/" aiohttp = ["evo-sdk-common[aiohttp]"] notebooks = ["evo-sdk-common[notebooks]"] utils = ["pyarrow", "pyarrow-stubs", "pandas", "numpy"] +blockmodels = ["evo-blockmodels[pyarrow]>=0.2.0"] [dependency-groups] # Dev dependencies. The version is left unspecified so the latest is installed. test = [ - "evo-objects[aiohttp,utils]", + "evo-objects[aiohttp,utils,blockmodels]", "pandas", "parameterized==0.9.0", "pytest", diff --git a/packages/evo-objects/src/evo/objects/typed/__init__.py b/packages/evo-objects/src/evo/objects/typed/__init__.py index f73440c1..83391e53 100644 --- a/packages/evo-objects/src/evo/objects/typed/__init__.py +++ b/packages/evo-objects/src/evo/objects/typed/__init__.py @@ -11,6 +11,14 @@ from .attributes import Attribute, Attributes from .base import BaseObject, object_from_path, object_from_reference, object_from_uuid +from .block_model_ref import ( + BlockModel, + BlockModelAttribute, + BlockModelAttributes, + BlockModelData, + BlockModelGeometry, + RegularBlockModelData, +) from .pointset import ( Locations, PointSet, @@ -60,6 +68,11 @@ "Attributes", "BaseObject", "BaseSpatialObject", + "BlockModel", + "BlockModelAttribute", + "BlockModelAttributes", + "BlockModelData", + "BlockModelGeometry", "BoundingBox", "CoordinateReferenceSystem", "CubicStructure", @@ -77,6 +90,7 @@ "PointSetData", "Regular3DGrid", "Regular3DGridData", + "RegularBlockModelData", "RegularMasked3DGrid", "RegularMasked3DGridData", "Rotation", @@ -94,3 +108,27 @@ "object_from_reference", "object_from_uuid", ] + +# Conditionally export report types when evo-blockmodels is installed +try: + from evo.blockmodels.typed import ( # noqa: F401 + Aggregation, + MassUnits, + Report, + ReportCategorySpec, + ReportColumnSpec, + ReportResult, + ReportSpecificationData, + ) + + __all__ += [ + "Aggregation", + "MassUnits", + "Report", + "ReportCategorySpec", + "ReportColumnSpec", + "ReportResult", + "ReportSpecificationData", + ] +except ImportError: + pass diff --git a/packages/evo-objects/src/evo/objects/typed/block_model_ref.py b/packages/evo-objects/src/evo/objects/typed/block_model_ref.py new file mode 100644 index 00000000..263a3555 --- /dev/null +++ b/packages/evo-objects/src/evo/objects/typed/block_model_ref.py @@ -0,0 +1,925 @@ +# Copyright © 2025 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed access for block models. + +A BlockModel is a Geoscience Object that references a block model stored in the +Block Model Service. It acts as a proxy, providing typed access to the block model's +geometry, attributes, and data through the Block Model Service API. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import Annotated, Any, Literal +from uuid import UUID + +from evo.common import IContext, IFeedback +from evo.common.utils import NoFeedback +from evo.objects import ObjectReference, SchemaVersion + +from . import object_from_uuid +from ._model import SchemaLocation +from .spatial import BaseSpatialObject, BaseSpatialObjectData +from .types import BoundingBox, EpsgCode, Point3, Size3d, Size3i + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +# Optional dependency: evo-blockmodels +try: + import pandas as pd +except ImportError: + _PD_AVAILABLE = False +else: + _PD_AVAILABLE = True + +try: + from evo.blockmodels import BlockModelAPIClient + from evo.blockmodels import RegularBlockModel as BMRegularBlockModel + from evo.blockmodels import RegularBlockModelData as BMRegularBlockModelData + from evo.blockmodels.data import BlockModel as BlockModelMetadata + from evo.blockmodels.data import RegularGridDefinition, Version + from evo.blockmodels.typed import Point3 as BMPoint3 + from evo.blockmodels.typed import Report, ReportSpecificationData + from evo.blockmodels.typed import Size3d as BMSize3d + from evo.blockmodels.typed import Size3i as BMSize3i + from evo.blockmodels.typed.base import BaseTypedBlockModel +except ImportError: + _BLOCKMODELS_AVAILABLE = False +else: + _BLOCKMODELS_AVAILABLE = True + + +def _require_blockmodels(operation: str = "This operation") -> None: + """Raise ImportError if evo-blockmodels is not installed.""" + if not _BLOCKMODELS_AVAILABLE: + raise ImportError( + f"{operation} requires the 'evo-blockmodels' package. Install it with: pip install evo-objects[blockmodels]" + ) + + +__all__ = [ + "BlockModel", + "BlockModelAttribute", + "BlockModelAttributes", + "BlockModelData", + "BlockModelGeometry", + "BlockModelPendingAttribute", + "RegularBlockModelData", +] + + +@dataclass(frozen=True, kw_only=True) +class BlockModelGeometry: + """The geometry definition of a regular block model.""" + + model_type: str + origin: Point3 + n_blocks: Size3i + block_size: Size3d + rotation: tuple[float, float, float] | None = None + + +class BlockModelAttribute: + """An attribute on a block model. + + This class represents an existing attribute on a block model. It stores a reference + to the parent BlockModel via `_obj`, similar to how `Attribute` in dataset.py works. + """ + + def __init__( + self, + name: str, + attribute_type: str, + block_model_column_uuid: UUID | None = None, + unit: str | None = None, + obj: "BlockModel | None" = None, + ): + self._name = name + self._attribute_type = attribute_type + self._block_model_column_uuid = block_model_column_uuid + self._unit = unit + self._obj = obj # Reference to parent BlockModel, similar to Attribute._obj + + @property + def name(self) -> str: + """The name of this attribute.""" + return self._name + + @property + def attribute_type(self) -> str: + """The type of this attribute.""" + return self._attribute_type + + @property + def block_model_column_uuid(self) -> UUID | None: + """The UUID of the column in the block model service.""" + return self._block_model_column_uuid + + @property + def unit(self) -> str | None: + """The unit of this attribute.""" + return self._unit + + @property + def exists(self) -> bool: + """Whether this attribute exists on the block model. + + :return: True for existing attributes. + """ + return True + + @property + def expression(self) -> str: + """The JMESPath expression to access this attribute from the object.""" + return f"attributes[?name=='{self._name}']" + + def to_target_dict(self) -> dict[str, str]: + """Serialize this attribute as a target for compute tasks. + + For existing attributes, returns an update operation referencing this attribute by name. + + :return: A dictionary with operation type and reference. + """ + return { + "operation": "update", + "reference": self.expression, + } + + def __repr__(self) -> str: + return f"BlockModelAttribute(name={self._name!r}, attribute_type={self._attribute_type!r}, unit={self._unit!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BlockModelAttribute): + return NotImplemented + return ( + self._name == other._name + and self._attribute_type == other._attribute_type + and self._block_model_column_uuid == other._block_model_column_uuid + and self._unit == other._unit + ) + + def __hash__(self) -> int: + return hash((self._name, self._attribute_type, self._block_model_column_uuid, self._unit)) + + +class BlockModelPendingAttribute: + """A placeholder for an attribute that doesn't exist yet on a Block Model. + + This is returned when accessing an attribute by name that doesn't exist. + It can be used as a target for compute tasks, which will create the attribute. + + Stores a reference to the parent BlockModel via `_obj`, similar to how + `BlockModelAttribute` and `Attribute` (in dataset.py) work. + """ + + def __init__(self, obj: "BlockModel", name: str) -> None: + """ + :param obj: The BlockModel this pending attribute belongs to. + :param name: The name of the attribute to create. + """ + self._obj = obj # Reference to parent BlockModel + self._name = name + + @property + def name(self) -> str: + """The name of this attribute.""" + return self._name + + @property + def expression(self) -> str: + """The JMESPath expression to access this attribute from the object.""" + return f"attributes[?name=='{self._name}']" + + @property + def exists(self) -> bool: + """Whether this attribute exists on the block model. + + :return: False for pending attributes. + """ + return False + + def to_target_dict(self) -> dict[str, str]: + """Serialize this attribute as a target for compute tasks. + + For pending attributes, returns a create operation with the attribute name. + + :return: A dictionary with operation type and name. + """ + return { + "operation": "create", + "name": self._name, + } + + def __repr__(self) -> str: + return f"BlockModelPendingAttribute(name={self._name!r}, exists=False)" + + +class BlockModelAttributes: + """A collection of attributes on a block model with pretty-printing support.""" + + def __init__(self, attributes: list[BlockModelAttribute], block_model: "BlockModel | None" = None): + self._block_model = block_model + # Set _obj reference on each attribute to the parent BlockModel + self._attributes = [] + for attr in attributes: + # Create a new attribute with _obj reference to the block model + attr_with_obj = BlockModelAttribute( + name=attr.name, + attribute_type=attr.attribute_type, + block_model_column_uuid=attr.block_model_column_uuid, + unit=attr.unit, + obj=block_model, + ) + self._attributes.append(attr_with_obj) + + def __iter__(self): + return iter(self._attributes) + + def __len__(self): + return len(self._attributes) + + def __getitem__(self, index_or_name: int | str) -> BlockModelAttribute | BlockModelPendingAttribute: + if isinstance(index_or_name, str): + for attr in self._attributes: + if attr.name == index_or_name: + return attr + # Return a BlockModelPendingAttribute for non-existent attributes accessed by name + # Pass the block model directly as _obj + return BlockModelPendingAttribute(self._block_model, index_or_name) + return self._attributes[index_or_name] + + def __repr__(self) -> str: + names = [attr.name for attr in self._attributes] + return f"BlockModelAttributes({names})" + + +if _BLOCKMODELS_AVAILABLE and _PD_AVAILABLE: + + @dataclass(frozen=True, kw_only=True) + class RegularBlockModelData: + """Data for creating a regular block model. + + This creates a new block model in the Block Model Service and a corresponding + Geoscience Object reference. + + :param name: The name of the block model. + :param origin: The origin point (x, y, z) of the block model. + :param n_blocks: The number of blocks in each dimension (nx, ny, nz). + :param block_size: The size of each block (dx, dy, dz). + :param cell_data: DataFrame with block data. Must contain (x, y, z) or (i, j, k) columns. + :param description: Optional description. + :param crs: Coordinate reference system (e.g., "EPSG:28354"). + :param size_unit_id: Unit for block sizes (e.g., "m"). + :param units: Dictionary mapping column names to unit IDs. + """ + + name: str + origin: Point3 + n_blocks: Size3i + block_size: Size3d + cell_data: "pd.DataFrame | None" = None + description: str | None = None + crs: str | None = None + size_unit_id: str | None = None + units: dict[str, str] = field(default_factory=dict) + +else: + + @dataclass(frozen=True, kw_only=True) + class RegularBlockModelData: # type: ignore[no-redef] + """Data for creating a regular block model. + + Requires evo-blockmodels to be installed: pip install evo-objects[blockmodels] + """ + + name: str + origin: Point3 + n_blocks: Size3i + block_size: Size3d + description: str | None = None + crs: str | None = None + size_unit_id: str | None = None + units: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True, kw_only=True) +class BlockModelData(BaseSpatialObjectData): + """Data for creating a BlockModel reference. + + A BlockModel is a reference to a block model stored in the Block Model Service. + This creates a Geoscience Object that points to an existing block model. + + :param name: The name of the block model reference object. + :param block_model_uuid: The UUID of the block model in the Block Model Service. + :param block_model_version_uuid: Optional specific version UUID to reference. + :param geometry: The geometry definition of the block model. + :param attributes: List of attributes available on the block model. + :param coordinate_reference_system: Optional CRS for the block model. + """ + + block_model_uuid: UUID + block_model_version_uuid: UUID | None = None + geometry: BlockModelGeometry + attributes: list[BlockModelAttribute] = field(default_factory=list) + + def compute_bounding_box(self) -> BoundingBox: + """Compute the bounding box from the geometry.""" + geom = self.geometry + return BoundingBox( + min_x=geom.origin.x, + max_x=geom.origin.x + geom.n_blocks.nx * geom.block_size.dx, + min_y=geom.origin.y, + max_y=geom.origin.y + geom.n_blocks.ny * geom.block_size.dy, + min_z=geom.origin.z, + max_z=geom.origin.z + geom.n_blocks.nz * geom.block_size.dz, + ) + + +def _parse_geometry(geometry_dict: dict) -> BlockModelGeometry: + """Parse geometry from the schema format.""" + model_type = geometry_dict.get("model_type", "regular") + origin = geometry_dict.get("origin", [0, 0, 0]) + n_blocks = geometry_dict.get("n_blocks", [1, 1, 1]) + block_size = geometry_dict.get("block_size", [1, 1, 1]) + rotation = geometry_dict.get("rotation") + + rotation_tuple = None + if rotation: + rotation_tuple = ( + rotation.get("dip_azimuth", 0), + rotation.get("dip", 0), + rotation.get("pitch", 0), + ) + + return BlockModelGeometry( + model_type=model_type, + origin=Point3(x=origin[0], y=origin[1], z=origin[2]), + n_blocks=Size3i(nx=n_blocks[0], ny=n_blocks[1], nz=n_blocks[2]), + block_size=Size3d(dx=block_size[0], dy=block_size[1], dz=block_size[2]), + rotation=rotation_tuple, + ) + + +def _serialize_geometry(geometry: BlockModelGeometry) -> dict: + """Serialize geometry to the schema format.""" + result = { + "model_type": geometry.model_type, + "origin": [geometry.origin.x, geometry.origin.y, geometry.origin.z], + "n_blocks": [geometry.n_blocks.nx, geometry.n_blocks.ny, geometry.n_blocks.nz], + "block_size": [geometry.block_size.dx, geometry.block_size.dy, geometry.block_size.dz], + } + if geometry.rotation: + result["rotation"] = { + "dip_azimuth": geometry.rotation[0], + "dip": geometry.rotation[1], + "pitch": geometry.rotation[2], + } + return result + + +def _parse_attributes(attributes_list: list[dict]) -> list[BlockModelAttribute]: + """Parse attributes from the schema format.""" + result = [] + for attr in attributes_list: + col_uuid = attr.get("block_model_column_uuid") + # Try to parse as UUID, but handle invalid formats gracefully + parsed_uuid = None + if col_uuid: + try: + parsed_uuid = UUID(col_uuid) + except (ValueError, AttributeError): + # col_uuid is not a valid UUID format, skip it + pass + result.append( + BlockModelAttribute( + name=attr.get("name", ""), + attribute_type=attr.get("attribute_type", "Float64"), + block_model_column_uuid=parsed_uuid, + unit=attr.get("unit"), + ) + ) + return result + + +def _serialize_attributes(attributes: list[BlockModelAttribute]) -> list[dict]: + """Serialize attributes to the schema format.""" + result = [] + for attr in attributes: + attr_dict = { + "name": attr.name, + "attribute_type": attr.attribute_type, + } + if attr.block_model_column_uuid: + attr_dict["block_model_column_uuid"] = str(attr.block_model_column_uuid) + if attr.unit: + attr_dict["unit"] = attr.unit + result.append(attr_dict) + return result + + +class BlockModel(BaseSpatialObject): + """A GeoscienceObject representing a block model. + + This object acts as a proxy, allowing you to access block model data and attributes + through the Block Model Service while the reference itself is stored as a Geoscience Object. + + Metadata-only operations (geometry, attributes, name) always work. Data operations + (to_dataframe, add_attribute, create_report, etc.) require the evo-blockmodels package + to be installed: ``pip install evo-objects[blockmodels]`` + + Example usage: + + # Create a new regular block model + data = RegularBlockModelData( + name="My Block Model", + origin=Point3(x=0, y=0, z=0), + n_blocks=Size3i(nx=10, ny=10, nz=5), + block_size=Size3d(dx=2.5, dy=5.0, dz=5.0), + cell_data=my_dataframe, + ) + bm = await BlockModel.create_regular(context, data) + + # Get an existing block model + bm = await BlockModel.from_reference(context, reference) + + # Access geometry + print(f"Origin: {bm.geometry.origin}") + print(f"Size: {bm.geometry.n_blocks}") + + # Access data through the Block Model Service (requires evo-blockmodels) + df = await bm.to_dataframe(columns=["*"]) + + # Create a new attribute on the block model (requires evo-blockmodels) + await bm.add_attribute(data_df, "new_attribute") + """ + + _data_class = BlockModelData + + sub_classification = "block-model" + creation_schema_version = SchemaVersion(major=1, minor=0, patch=0) + + # Schema properties + block_model_uuid: Annotated[UUID, SchemaLocation("block_model_uuid")] + + block_model_version_uuid: Annotated[UUID | None, SchemaLocation("block_model_version_uuid")] + + _geometry_raw: Annotated[dict, SchemaLocation("geometry")] + + _attributes_raw: Annotated[list[dict], SchemaLocation("attributes")] = [] + + @property + def geometry(self) -> BlockModelGeometry: + """The geometry definition of the block model.""" + return _parse_geometry(self._geometry_raw) + + @property + def attributes(self) -> BlockModelAttributes: + """The attributes available on this block model.""" + return BlockModelAttributes(_parse_attributes(self._attributes_raw), block_model=self) + + def get_attribute(self, name: str) -> BlockModelAttribute | None: + """Get an attribute by name. + + :param name: The name of the attribute. + :return: The attribute, or None if not found. + """ + for attr in self.attributes: + if attr.name == name: + return attr + return None + + def _get_block_model_client(self) -> "BlockModelAPIClient": + """Get a BlockModelAPIClient for the current context. + + :raises ImportError: If evo-blockmodels is not installed. + """ + _require_blockmodels("Accessing block model data") + return BlockModelAPIClient.from_context(self._api_context) + + async def _get_or_create_typed_block_model(self) -> "BaseTypedBlockModel": + """Lazily create a typed block model delegate for data operations. + + All data operations are delegated to a BaseTypedBlockModel instance + (currently RegularBlockModel), avoiding code duplication between the + reference object and typed objects in evo-blockmodels. + + :raises ImportError: If evo-blockmodels is not installed. + """ + _require_blockmodels("Accessing block model data") + + if not hasattr(self, "_typed_bm") or self._typed_bm is None: + client = self._get_block_model_client() + bm_metadata = await client.get_block_model(self.block_model_uuid) + versions = await client.list_versions(self.block_model_uuid) + version = versions[0] if versions else None + + self._typed_bm = BMRegularBlockModel( + client=client, + metadata=bm_metadata, + version=version, + cell_data=pd.DataFrame(), + context=self._api_context, + ) + + return self._typed_bm + + async def get_block_model_metadata(self) -> "BlockModelMetadata": + """Get the full block model metadata from the Block Model Service. + + :return: The BlockModel metadata from the Block Model Service. + :raises ImportError: If evo-blockmodels is not installed. + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.get_block_model_metadata() + + async def get_versions(self) -> "list[Version]": + """Get all versions of this block model. + + :return: List of versions, ordered from newest to oldest. + :raises ImportError: If evo-blockmodels is not installed. + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.get_versions() + + async def to_dataframe( + self, + columns: list[str] | None = None, + version_uuid: "UUID | None | Literal['latest']" = "latest", + fb: IFeedback = NoFeedback, + ) -> "pd.DataFrame": + """Get block model data as a DataFrame. + + This is the preferred method for accessing block model data. It retrieves + the data from the Block Model Service and returns it as a pandas DataFrame. + + :param columns: List of column names to retrieve. Defaults to all columns ["*"]. + :param version_uuid: Specific version to query. Use "latest" (default) to get the latest version, + or None to use the version referenced by this object. + :param fb: Optional feedback interface for progress reporting. + :return: DataFrame containing the block model data with user-friendly column names. + :raises ImportError: If evo-blockmodels is not installed. + + Example: + >>> df = await block_model.to_dataframe() + >>> df.head() + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.to_dataframe(columns=columns, version_uuid=version_uuid, fb=fb) + + async def refresh(self) -> "BlockModel": + """Refresh this block model object with the latest data from the server. + + Use this after a remote operation (like kriging) has updated the block model + to see the newly added attributes. + + :return: A new BlockModel instance with refreshed data. + + Example: + >>> # After running kriging that adds attributes... + >>> block_model = await block_model.refresh() + >>> block_model.attributes # Now shows the new attributes + """ + # Refresh the typed block model delegate if it exists, so it's immediately up-to-date + if hasattr(self, "_typed_bm") and self._typed_bm is not None: + await self._typed_bm.refresh() + return await object_from_uuid(self._api_context, self.metadata.id) + + async def add_attribute( + self, + data: "pd.DataFrame", + attribute_name: str, + unit: str | None = None, + fb: IFeedback = NoFeedback, + ) -> "Version": + """Add a new attribute to the block model. + + The DataFrame must contain geometry columns (i, j, k) or (x, y, z) and the + attribute column to add. + + :param data: DataFrame containing geometry columns and the new attribute. + :param attribute_name: Name of the attribute column in the DataFrame to add. + :param unit: Optional unit ID for the attribute (must be a valid unit ID from the Block Model Service). + :param fb: Optional feedback interface for progress reporting. + :return: The new version created by adding the attribute. + :raises ImportError: If evo-blockmodels is not installed. + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.add_attribute(data, attribute_name, unit=unit, fb=fb) + + async def update_attributes( + self, + data: "pd.DataFrame", + new_columns: list[str] | None = None, + update_columns: set[str] | None = None, + delete_columns: set[str] | None = None, + units: dict[str, str] | None = None, + fb: IFeedback = NoFeedback, + ) -> "Version": + """Update attributes on the block model. + + :param data: DataFrame containing geometry columns and attribute data. + :param new_columns: List of new column names to add. + :param update_columns: Set of existing column names to update. + :param delete_columns: Set of column names to delete. + :param units: Dictionary mapping column names to unit IDs (must be valid unit IDs from the Block Model Service). + :param fb: Optional feedback interface for progress reporting. + :return: The new version created by the update. + :raises ImportError: If evo-blockmodels is not installed. + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.update_attributes( + data, + new_columns=new_columns, + update_columns=update_columns, + delete_columns=delete_columns, + units=units, + fb=fb, + ) + + @classmethod + async def _data_to_dict(cls, data: BlockModelData, context: IContext) -> dict[str, Any]: + """Convert BlockModelData to a dictionary for creating the Geoscience Object.""" + if cls.creation_schema_version is None: + raise NotImplementedError("creation_schema_version must be defined") + + result: dict[str, Any] = { + "schema": f"/objects/block-model/{cls.creation_schema_version}/block-model.schema.json", + "name": data.name, + "block_model_uuid": str(data.block_model_uuid), + "geometry": _serialize_geometry(data.geometry), + } + + if data.description: + result["description"] = data.description + + if data.block_model_version_uuid: + result["block_model_version_uuid"] = str(data.block_model_version_uuid) + + if data.coordinate_reference_system: + if isinstance(data.coordinate_reference_system, EpsgCode): + result["coordinate_reference_system"] = {"epsg_code": int(data.coordinate_reference_system)} + else: + result["coordinate_reference_system"] = {"ogc_wkt": data.coordinate_reference_system} + + if data.attributes: + result["attributes"] = _serialize_attributes(data.attributes) + + # Compute and set bounding box + bbox = data.compute_bounding_box() + result["bounding_box"] = { + "min_x": bbox.min_x, + "max_x": bbox.max_x, + "min_y": bbox.min_y, + "max_y": bbox.max_y, + "min_z": bbox.min_z, + "max_z": bbox.max_z, + } + + return result + + @classmethod + async def create_regular( + cls, + context: IContext, + data: RegularBlockModelData, + path: str | None = None, + fb: IFeedback = NoFeedback, + ) -> Self: + """Create a new regular block model. + + This creates a block model in the Block Model Service and a corresponding + Geoscience Object reference. + + :param context: The context containing environment, connector, and cache. + :param data: The data defining the regular block model to create. + :param path: Optional path for the Geoscience Object. + :param fb: Optional feedback interface for progress reporting. + :return: A new BlockModel instance. + :raises ImportError: If evo-blockmodels is not installed. + """ + _require_blockmodels("Creating block models") + + fb.progress(0.0, "Creating block model...") + + # Convert to evo-blockmodels data format + bm_data = BMRegularBlockModelData( + name=data.name, + description=data.description, + origin=BMPoint3(data.origin.x, data.origin.y, data.origin.z), + n_blocks=BMSize3i(data.n_blocks.nx, data.n_blocks.ny, data.n_blocks.nz), + block_size=BMSize3d(data.block_size.dx, data.block_size.dy, data.block_size.dz), + cell_data=data.cell_data, + coordinate_reference_system=data.crs, + size_unit_id=data.size_unit_id, + units=data.units, + ) + + # Create the block model via Block Model Service + bm = await BMRegularBlockModel.create(context, bm_data, path=path) + + fb.progress(0.6, "Loading block model reference...") + + # Load the Geoscience Object that was created + goose_id = bm.metadata.geoscience_object_id + if goose_id is None: + raise RuntimeError("Block model was created but geoscience_object_id is not set") + + object_ref = ObjectReference.new( + environment=context.get_environment(), + object_id=goose_id, + ) + + result = await cls.from_reference(context, object_ref) + + fb.progress(1.0, "Block model created") + return result + + @classmethod + async def from_block_model( + cls, + context: IContext, + block_model_uuid: UUID, + name: str | None = None, + version_uuid: UUID | None = None, + path: str | None = None, + fb: IFeedback = NoFeedback, + ) -> Self: + """Create a BlockModel from an existing block model in the Block Model Service. + + This fetches the block model metadata from the Block Model Service and creates + a corresponding Geoscience Object reference. + + :param context: The context containing environment, connector, and cache. + :param block_model_uuid: UUID of the block model in the Block Model Service. + :param name: Optional name for the reference object. Defaults to the block model name. + :param version_uuid: Optional specific version to reference. + :param path: Optional path for the Geoscience Object. + :param fb: Optional feedback interface for progress reporting. + :return: A new BlockModel instance. + :raises ImportError: If evo-blockmodels is not installed. + """ + _require_blockmodels("Creating block model references from the Block Model Service") + client = BlockModelAPIClient.from_context(context) + + fb.progress(0.0, "Fetching block model metadata...") + + # Get block model metadata + bm = await client.get_block_model(block_model_uuid) + + fb.progress(0.3, "Fetching version information...") + + # Get version info if not specified + if version_uuid is None: + versions = await client.list_versions(block_model_uuid) + if versions: + version_uuid = versions[0].version_uuid + + fb.progress(0.5, "Creating reference object...") + + # Build geometry from the block model + grid_def = bm.grid_definition + if not isinstance(grid_def, RegularGridDefinition): + raise ValueError(f"Only regular block models are supported, got {type(grid_def).__name__}") + + rotation_tuple = None + if grid_def.rotations: + # Convert rotations to (dip_azimuth, dip, pitch) - simplified + rotation_tuple = (0.0, 0.0, 0.0) # Default, would need proper conversion + + geometry = BlockModelGeometry( + model_type="regular", + origin=Point3(x=grid_def.model_origin[0], y=grid_def.model_origin[1], z=grid_def.model_origin[2]), + n_blocks=Size3i(nx=grid_def.n_blocks[0], ny=grid_def.n_blocks[1], nz=grid_def.n_blocks[2]), + block_size=Size3d(dx=grid_def.block_size[0], dy=grid_def.block_size[1], dz=grid_def.block_size[2]), + rotation=rotation_tuple, + ) + + # Build attributes from version info + attributes: list[BlockModelAttribute] = [] + if version_uuid: + versions = await client.list_versions(block_model_uuid) + version = next((v for v in versions if v.version_uuid == version_uuid), None) + if version and version.columns: + for col in version.columns: + # Try to parse col_id as UUID, but it might not be valid for system columns + col_uuid = None + if col.col_id: + try: + col_uuid = UUID(col.col_id) + except ValueError: + # Not a valid UUID (e.g., system column), skip + pass + attributes.append( + BlockModelAttribute( + name=col.title, + attribute_type=col.data_type.value if col.data_type else "Float64", + block_model_column_uuid=col_uuid, + ) + ) + + # Determine CRS + crs: EpsgCode | str | None = None + if bm.coordinate_reference_system: + if bm.coordinate_reference_system.startswith("EPSG:"): + try: + crs = EpsgCode(int(bm.coordinate_reference_system.split(":")[1])) + except ValueError: + crs = bm.coordinate_reference_system + else: + crs = bm.coordinate_reference_system + + ref_data = BlockModelData( + name=name or bm.name, + block_model_uuid=block_model_uuid, + block_model_version_uuid=version_uuid, + geometry=geometry, + attributes=attributes, + coordinate_reference_system=crs, + ) + + fb.progress(0.8, "Saving reference...") + + result = await cls.create(context, ref_data, path=path) + + fb.progress(1.0, "Block model reference created") + return result + + async def set_attribute_units( + self, + units: dict[str, str], + fb: IFeedback = NoFeedback, + ) -> "BlockModel": + """Set units for attributes on this block model. + + This is required before creating reports, as reports need columns to have + units defined. + + :param units: Dictionary mapping attribute names to unit IDs (e.g., {"Au": "g/t", "density": "t/m3"}). + :param fb: Optional feedback interface for progress reporting. + :return: The updated BlockModel instance (refreshed from server). + :raises ImportError: If evo-blockmodels is not installed. + + Example: + >>> from evo.blockmodels import Units + >>> block_model = await block_model.set_attribute_units({ + ... "Au": Units.GRAMS_PER_TONNE, + ... "density": Units.TONNES_PER_CUBIC_METRE, + ... }) + """ + typed_bm = await self._get_or_create_typed_block_model() + await typed_bm.set_attribute_units(units, fb=fb) + return await self.refresh() + + async def create_report( + self, + data: "ReportSpecificationData", + fb: IFeedback = NoFeedback, + ) -> "Report": + """Create a new report specification for this block model. + + Reports require: + 1. Columns to have units set (use `set_attribute_units()` first) + 2. At least one category column for grouping (e.g., domain, rock type) + + :param data: The report specification data. + :param fb: Optional feedback interface for progress reporting. + :return: A Report instance representing the created report. + :raises ImportError: If evo-blockmodels is not installed. + + Example: + >>> from evo.blockmodels.typed import ReportSpecificationData, ReportColumnSpec, ReportCategorySpec + >>> report = await block_model.create_report(ReportSpecificationData( + ... name="Gold Resource Report", + ... columns=[ReportColumnSpec(column_name="Au", aggregation="WEIGHTED_MEAN", output_unit_id="g/t")], + ... categories=[ReportCategorySpec(column_name="domain")], + ... mass_unit_id="t", + ... density_value=2.7, + ... density_unit_id="t/m3", + ... )) + >>> report # Pretty-prints with BlockSync link + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.create_report(data, fb=fb) + + async def list_reports(self, fb: IFeedback = NoFeedback) -> "list[Report]": + """List all report specifications for this block model. + + :param fb: Optional feedback interface for progress reporting. + :return: List of Report instances. + :raises ImportError: If evo-blockmodels is not installed. + """ + typed_bm = await self._get_or_create_typed_block_model() + return await typed_bm.list_reports(fb=fb) diff --git a/packages/evo-objects/src/evo/objects/typed/types.py b/packages/evo-objects/src/evo/objects/typed/types.py index 8d2744a3..450c3fbb 100644 --- a/packages/evo-objects/src/evo/objects/typed/types.py +++ b/packages/evo-objects/src/evo/objects/typed/types.py @@ -12,13 +12,16 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Annotated, Any, NamedTuple, overload +from typing import Annotated, Any, overload import numpy as np import numpy.typing as npt import pydantic from pydantic_core import core_schema +# Import basic geometry types from evo.common and re-export +from evo.common.typed import Point3, Size3d, Size3i + __all__ = [ "BoundingBox", "CoordinateReferenceSystem", @@ -92,35 +95,6 @@ def _load_crs(value: Any) -> EpsgCode | str | None: ] -class Point3(NamedTuple): - """A 3D point defined by X, Y, and Z coordinates.""" - - x: float - y: float - z: float - - -class Size3d(NamedTuple): - """A 3D size defined by dx, dy, and dz dimensions.""" - - dx: float - dy: float - dz: float - - -class Size3i(NamedTuple): - """A 3D size defined by nx, ny, and nz integer dimensions.""" - - nx: int - ny: int - nz: int - - @property - def total_size(self) -> int: - """The total size (number of elements) represented by this Size3i.""" - return self.nx * self.ny * self.nz - - @dataclass(frozen=True) class BoundingBox: """A bounding box defined by minimum and maximum coordinates.""" diff --git a/packages/evo-objects/tests/typed/test_block_model_ref.py b/packages/evo-objects/tests/typed/test_block_model_ref.py new file mode 100644 index 00000000..3db924a6 --- /dev/null +++ b/packages/evo-objects/tests/typed/test_block_model_ref.py @@ -0,0 +1,581 @@ +# Copyright © 2025 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import uuid +from unittest import TestCase + +from evo.objects.typed import ( + BlockModelAttribute, + BlockModelData, + BlockModelGeometry, + Point3, + Size3d, + Size3i, +) +from evo.objects.typed.block_model_ref import ( + _parse_attributes, + _parse_geometry, + _serialize_attributes, + _serialize_geometry, +) + + +class TestBlockModelGeometry(TestCase): + def test_create_geometry(self): + """Test creating a BlockModelGeometry.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(100.0, 200.0, 300.0), + n_blocks=Size3i(10, 20, 30), + block_size=Size3d(1.0, 2.0, 3.0), + ) + + self.assertEqual(geom.model_type, "regular") + self.assertEqual(geom.origin, Point3(100.0, 200.0, 300.0)) + self.assertEqual(geom.n_blocks, Size3i(10, 20, 30)) + self.assertEqual(geom.block_size, Size3d(1.0, 2.0, 3.0)) + self.assertIsNone(geom.rotation) + + def test_create_geometry_with_rotation(self): + """Test creating a BlockModelGeometry with rotation.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + rotation=(45.0, 30.0, 15.0), + ) + + self.assertEqual(geom.rotation, (45.0, 30.0, 15.0)) + + +class TestBlockModelAttribute(TestCase): + def test_create_attribute(self): + """Test creating a BlockModelAttribute.""" + attr = BlockModelAttribute( + name="grade", + attribute_type="Float64", + block_model_column_uuid=uuid.uuid4(), + unit="g/t", + ) + + self.assertEqual(attr.name, "grade") + self.assertEqual(attr.attribute_type, "Float64") + self.assertIsNotNone(attr.block_model_column_uuid) + self.assertEqual(attr.unit, "g/t") + + def test_create_attribute_minimal(self): + """Test creating a BlockModelAttribute with minimal parameters.""" + attr = BlockModelAttribute( + name="density", + attribute_type="Float32", + ) + + self.assertEqual(attr.name, "density") + self.assertEqual(attr.attribute_type, "Float32") + self.assertIsNone(attr.block_model_column_uuid) + self.assertIsNone(attr.unit) + + +class TestBlockModelData(TestCase): + def test_create_data(self): + """Test creating BlockModelData.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + ) + bm_uuid = uuid.uuid4() + + data = BlockModelData( + name="Test Block Model", + block_model_uuid=bm_uuid, + geometry=geom, + ) + + self.assertEqual(data.name, "Test Block Model") + self.assertEqual(data.block_model_uuid, bm_uuid) + self.assertEqual(data.geometry, geom) + self.assertIsNone(data.block_model_version_uuid) + self.assertEqual(data.attributes, []) + + def test_compute_bounding_box(self): + """Test computing bounding box from geometry.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(100.0, 200.0, 300.0), + n_blocks=Size3i(10, 20, 30), + block_size=Size3d(1.0, 2.0, 3.0), + ) + + data = BlockModelData( + name="Test", + block_model_uuid=uuid.uuid4(), + geometry=geom, + ) + + bbox = data.compute_bounding_box() + + self.assertEqual(bbox.min_x, 100.0) + self.assertEqual(bbox.max_x, 110.0) # 100 + 10 * 1 + self.assertEqual(bbox.min_y, 200.0) + self.assertEqual(bbox.max_y, 240.0) # 200 + 20 * 2 + self.assertEqual(bbox.min_z, 300.0) + self.assertEqual(bbox.max_z, 390.0) # 300 + 30 * 3 + + +class TestGeometrySerialization(TestCase): + def test_parse_geometry(self): + """Test parsing geometry from dictionary.""" + geometry_dict = { + "model_type": "regular", + "origin": [1.0, 2.0, 3.0], + "n_blocks": [10, 20, 30], + "block_size": [1.5, 2.5, 3.5], + } + + geom = _parse_geometry(geometry_dict) + + self.assertEqual(geom.model_type, "regular") + self.assertEqual(geom.origin, Point3(1.0, 2.0, 3.0)) + self.assertEqual(geom.n_blocks, Size3i(10, 20, 30)) + self.assertEqual(geom.block_size, Size3d(1.5, 2.5, 3.5)) + self.assertIsNone(geom.rotation) + + def test_parse_geometry_with_rotation(self): + """Test parsing geometry with rotation.""" + geometry_dict = { + "model_type": "regular", + "origin": [0, 0, 0], + "n_blocks": [10, 10, 10], + "block_size": [1, 1, 1], + "rotation": { + "dip_azimuth": 45.0, + "dip": 30.0, + "pitch": 15.0, + }, + } + + geom = _parse_geometry(geometry_dict) + + self.assertEqual(geom.rotation, (45.0, 30.0, 15.0)) + + def test_serialize_geometry(self): + """Test serializing geometry to dictionary.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(1.0, 2.0, 3.0), + n_blocks=Size3i(10, 20, 30), + block_size=Size3d(1.5, 2.5, 3.5), + ) + + result = _serialize_geometry(geom) + + self.assertEqual(result["model_type"], "regular") + self.assertEqual(result["origin"], [1.0, 2.0, 3.0]) + self.assertEqual(result["n_blocks"], [10, 20, 30]) + self.assertEqual(result["block_size"], [1.5, 2.5, 3.5]) + self.assertNotIn("rotation", result) + + def test_serialize_geometry_with_rotation(self): + """Test serializing geometry with rotation.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1, 1, 1), + rotation=(45.0, 30.0, 15.0), + ) + + result = _serialize_geometry(geom) + + self.assertIn("rotation", result) + self.assertEqual(result["rotation"]["dip_azimuth"], 45.0) + self.assertEqual(result["rotation"]["dip"], 30.0) + self.assertEqual(result["rotation"]["pitch"], 15.0) + + def test_round_trip_geometry(self): + """Test round-trip serialization of geometry.""" + original = BlockModelGeometry( + model_type="regular", + origin=Point3(100.0, 200.0, 300.0), + n_blocks=Size3i(10, 20, 30), + block_size=Size3d(1.5, 2.5, 3.5), + rotation=(45.0, 30.0, 15.0), + ) + + serialized = _serialize_geometry(original) + parsed = _parse_geometry(serialized) + + self.assertEqual(original.model_type, parsed.model_type) + self.assertEqual(original.origin, parsed.origin) + self.assertEqual(original.n_blocks, parsed.n_blocks) + self.assertEqual(original.block_size, parsed.block_size) + self.assertEqual(original.rotation, parsed.rotation) + + +class TestAttributeSerialization(TestCase): + def test_parse_attributes(self): + """Test parsing attributes from list of dictionaries.""" + attrs_list = [ + { + "name": "grade", + "attribute_type": "Float64", + "block_model_column_uuid": "12345678-1234-5678-1234-567812345678", + "unit": "g/t", + }, + { + "name": "density", + "attribute_type": "Float32", + }, + ] + + attrs = _parse_attributes(attrs_list) + + self.assertEqual(len(attrs), 2) + self.assertEqual(attrs[0].name, "grade") + self.assertEqual(attrs[0].attribute_type, "Float64") + self.assertEqual(attrs[0].unit, "g/t") + self.assertIsNotNone(attrs[0].block_model_column_uuid) + self.assertEqual(attrs[1].name, "density") + self.assertIsNone(attrs[1].block_model_column_uuid) + + def test_parse_attributes_with_invalid_uuid(self): + """Test parsing attributes handles invalid UUID strings gracefully.""" + attrs_list = [ + { + "name": "grade", + "attribute_type": "Float64", + "block_model_column_uuid": "i", # Invalid - geometry column ID + }, + { + "name": "x_coord", + "attribute_type": "Float64", + "block_model_column_uuid": "x", # Invalid - geometry column ID + }, + { + "name": "valid_attr", + "attribute_type": "Float64", + "block_model_column_uuid": "12345678-1234-5678-1234-567812345678", # Valid UUID + }, + ] + + attrs = _parse_attributes(attrs_list) + + self.assertEqual(len(attrs), 3) + # Invalid UUIDs should result in None + self.assertIsNone(attrs[0].block_model_column_uuid) + self.assertIsNone(attrs[1].block_model_column_uuid) + # Valid UUID should be parsed + self.assertIsNotNone(attrs[2].block_model_column_uuid) + self.assertEqual(str(attrs[2].block_model_column_uuid), "12345678-1234-5678-1234-567812345678") + + def test_parse_attributes_with_none_uuid(self): + """Test parsing attributes with None UUID.""" + attrs_list = [ + { + "name": "test", + "attribute_type": "Float64", + "block_model_column_uuid": None, + }, + ] + + attrs = _parse_attributes(attrs_list) + + self.assertEqual(len(attrs), 1) + self.assertIsNone(attrs[0].block_model_column_uuid) + + def test_parse_attributes_missing_uuid(self): + """Test parsing attributes without UUID field.""" + attrs_list = [ + { + "name": "test", + "attribute_type": "Float64", + }, + ] + + attrs = _parse_attributes(attrs_list) + + self.assertEqual(len(attrs), 1) + self.assertIsNone(attrs[0].block_model_column_uuid) + + def test_serialize_attributes(self): + """Test serializing attributes to list of dictionaries.""" + col_uuid = uuid.uuid4() + attrs = [ + BlockModelAttribute( + name="grade", + attribute_type="Float64", + block_model_column_uuid=col_uuid, + unit="g/t", + ), + BlockModelAttribute( + name="density", + attribute_type="Float32", + ), + ] + + result = _serialize_attributes(attrs) + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["name"], "grade") + self.assertEqual(result[0]["attribute_type"], "Float64") + self.assertEqual(result[0]["unit"], "g/t") + self.assertEqual(result[0]["block_model_column_uuid"], str(col_uuid)) + self.assertEqual(result[1]["name"], "density") + self.assertNotIn("unit", result[1]) + self.assertNotIn("block_model_column_uuid", result[1]) + + +class TestBlockModelAttributeTarget(TestCase): + """Tests for BlockModelAttribute and BlockModelPendingAttribute target functionality.""" + + def test_existing_attribute_exists_property(self): + """Test that existing attributes have exists=True.""" + attr = BlockModelAttribute( + name="grade", + attribute_type="Float64", + ) + self.assertTrue(attr.exists) + + def test_existing_attribute_to_target_dict(self): + """Test that existing attributes serialize to update operation with name-based reference.""" + attr = BlockModelAttribute( + name="grade", + attribute_type="Float64", + ) + target_dict = attr.to_target_dict() + + self.assertEqual(target_dict["operation"], "update") + self.assertIn("reference", target_dict) + self.assertEqual(target_dict["reference"], "attributes[?name=='grade']") + + def test_existing_attribute_expression(self): + """Test that existing attributes have correct JMESPath expression.""" + attr = BlockModelAttribute( + name="grade", + attribute_type="Float64", + ) + self.assertEqual(attr.expression, "attributes[?name=='grade']") + + def test_pending_attribute_exists_property(self): + """Test that pending attributes have exists=False.""" + from evo.objects.typed.block_model_ref import BlockModelPendingAttribute + + pending = BlockModelPendingAttribute(None, "new_attribute") + + self.assertFalse(pending.exists) + + def test_pending_attribute_to_target_dict(self): + """Test that pending attributes serialize to create operation.""" + from evo.objects.typed.block_model_ref import BlockModelPendingAttribute + + pending = BlockModelPendingAttribute(None, "new_attribute") + target_dict = pending.to_target_dict() + + self.assertEqual(target_dict["operation"], "create") + self.assertEqual(target_dict["name"], "new_attribute") + + def test_pending_attribute_expression(self): + """Test that pending attributes have correct JMESPath expression.""" + from evo.objects.typed.block_model_ref import BlockModelPendingAttribute + + pending = BlockModelPendingAttribute(None, "new_attribute") + + self.assertIn("new_attribute", pending.expression) + self.assertIn("attributes", pending.expression) + + def test_pending_attribute_repr(self): + """Test the string representation of BlockModelPendingAttribute.""" + from evo.objects.typed.block_model_ref import BlockModelPendingAttribute + + pending = BlockModelPendingAttribute(None, "new_attribute") + repr_str = repr(pending) + + self.assertIn("BlockModelPendingAttribute", repr_str) + self.assertIn("new_attribute", repr_str) + self.assertIn("exists=False", repr_str) + + def test_attributes_getitem_returns_pending_for_missing(self): + """Test that accessing a non-existent attribute returns PendingAttribute.""" + from evo.objects.typed.block_model_ref import BlockModelAttributes, BlockModelPendingAttribute + + existing_attrs = [ + BlockModelAttribute(name="grade", attribute_type="Float64"), + ] + attrs = BlockModelAttributes(existing_attrs, block_model=None) + + # Accessing existing attribute returns BlockModelAttribute + existing = attrs["grade"] + self.assertIsInstance(existing, BlockModelAttribute) + self.assertTrue(existing.exists) + + # Accessing non-existent attribute returns BlockModelPendingAttribute + pending = attrs["new_attribute"] + self.assertIsInstance(pending, BlockModelPendingAttribute) + self.assertFalse(pending.exists) + + def test_attributes_getitem_by_index(self): + """Test that accessing attributes by index works correctly.""" + from evo.objects.typed.block_model_ref import BlockModelAttributes + + existing_attrs = [ + BlockModelAttribute(name="grade", attribute_type="Float64"), + BlockModelAttribute(name="density", attribute_type="Float32"), + ] + attrs = BlockModelAttributes(existing_attrs, block_model=None) + + self.assertEqual(attrs[0].name, "grade") + self.assertEqual(attrs[1].name, "density") + + def test_attribute_has_obj_reference(self): + """Test that attributes have _obj reference to the parent BlockModel.""" + from evo.objects.typed.block_model_ref import BlockModelAttributes + + # Create a mock block model (using None for simplicity in unit tests) + mock_block_model = "mock_block_model" # In real use, this would be a BlockModel instance + + existing_attrs = [ + BlockModelAttribute(name="grade", attribute_type="Float64"), + ] + attrs = BlockModelAttributes(existing_attrs, block_model=mock_block_model) + + # The attribute should have _obj reference to the block model + attr = attrs["grade"] + self.assertEqual(attr._obj, mock_block_model) + + def test_pending_attribute_has_obj_reference(self): + """Test that pending attributes have _obj reference to the parent BlockModel.""" + from evo.objects.typed.block_model_ref import BlockModelAttributes, BlockModelPendingAttribute + + # Create a mock block model + mock_block_model = "mock_block_model" + + attrs = BlockModelAttributes([], block_model=mock_block_model) + + # Accessing non-existent attribute returns BlockModelPendingAttribute with _obj set + pending = attrs["new_attribute"] + self.assertIsInstance(pending, BlockModelPendingAttribute) + self.assertEqual(pending._obj, mock_block_model) + + +class TestBlockModelOptionalDependency(TestCase): + """Tests verifying that BlockModel metadata-only operations work regardless of + evo-blockmodels availability, and that data operations correctly use evo-blockmodels.""" + + def test_geometry_always_available(self): + """Test that geometry parsing works without data operations.""" + from evo.objects.typed.block_model_ref import _parse_geometry + + geometry_dict = { + "model_type": "regular", + "origin": [100.0, 200.0, 300.0], + "n_blocks": [10, 20, 30], + "block_size": [1.0, 2.0, 3.0], + } + + geom = _parse_geometry(geometry_dict) + self.assertEqual(geom.model_type, "regular") + self.assertEqual(geom.origin.x, 100.0) + + def test_attributes_always_available(self): + """Test that attribute parsing works without data operations.""" + from evo.objects.typed.block_model_ref import _parse_attributes + + attrs_list = [ + {"name": "grade", "attribute_type": "Float64"}, + ] + + attrs = _parse_attributes(attrs_list) + self.assertEqual(len(attrs), 1) + self.assertEqual(attrs[0].name, "grade") + + def test_block_model_data_compute_bounding_box(self): + """Test that bounding box computation works without blockmodels.""" + geom = BlockModelGeometry( + model_type="regular", + origin=Point3(100.0, 200.0, 300.0), + n_blocks=Size3i(10, 20, 30), + block_size=Size3d(1.0, 2.0, 3.0), + ) + data = BlockModelData( + name="Test", + block_model_uuid=uuid.uuid4(), + geometry=geom, + ) + bbox = data.compute_bounding_box() + self.assertEqual(bbox.min_x, 100.0) + self.assertEqual(bbox.max_x, 110.0) + + def test_require_blockmodels_function(self): + """Test that _require_blockmodels works when blockmodels IS available.""" + from evo.objects.typed.block_model_ref import _BLOCKMODELS_AVAILABLE, _require_blockmodels + + # In test environment, blockmodels should be available + self.assertTrue(_BLOCKMODELS_AVAILABLE) + # Should not raise when blockmodels is available + _require_blockmodels("Test operation") + + def test_regular_block_model_data_importable(self): + """Test that RegularBlockModelData is importable from evo.objects.typed.""" + from evo.objects.typed import RegularBlockModelData as RBD + + data = RBD( + name="Test", + origin=Point3(0, 0, 0), + n_blocks=Size3i(10, 10, 10), + block_size=Size3d(1.0, 1.0, 1.0), + ) + self.assertEqual(data.name, "Test") + + def test_report_types_importable_when_blockmodels_available(self): + """Test that report types are importable from evo.objects.typed when blockmodels installed.""" + from evo.objects.typed.block_model_ref import _BLOCKMODELS_AVAILABLE + + if _BLOCKMODELS_AVAILABLE: + from evo.objects.typed import Report, ReportSpecificationData + + self.assertIsNotNone(Report) + self.assertIsNotNone(ReportSpecificationData) + + def test_common_types_from_evo_common(self): + """Test that Point3, Size3d, Size3i are available from evo.common.typed.""" + from evo.common.typed import BoundingBox as CommonBBox + from evo.common.typed import Point3 as CommonPoint3 + from evo.common.typed import Size3d as CommonSize3d + from evo.common.typed import Size3i as CommonSize3i + + p = CommonPoint3(1.0, 2.0, 3.0) + self.assertEqual(p.x, 1.0) + + s = CommonSize3i(10, 20, 30) + self.assertEqual(s.total_size, 6000) + + sd = CommonSize3d(1.0, 2.0, 3.0) + self.assertEqual(sd.dx, 1.0) + + bbox = CommonBBox.from_origin_and_size(p, s, sd) + self.assertEqual(bbox.x_min, 1.0) + self.assertEqual(bbox.x_max, 11.0) + + def test_types_are_same_across_packages(self): + """Test that Point3/Size3d/Size3i from evo.common.typed and evo.objects.typed are the same type.""" + from evo.common.typed import Point3 as CommonPoint3 + from evo.common.typed import Size3d as CommonSize3d + from evo.common.typed import Size3i as CommonSize3i + from evo.objects.typed import Point3 as ObjectsPoint3 + from evo.objects.typed import Size3d as ObjectsSize3d + from evo.objects.typed import Size3i as ObjectsSize3i + + self.assertIs(CommonPoint3, ObjectsPoint3) + self.assertIs(CommonSize3d, ObjectsSize3d) + self.assertIs(CommonSize3i, ObjectsSize3i) diff --git a/packages/evo-sdk-common/src/evo/common/__init__.py b/packages/evo-sdk-common/src/evo/common/__init__.py index c9c06bbe..91fb1b1f 100644 --- a/packages/evo-sdk-common/src/evo/common/__init__.py +++ b/packages/evo-sdk-common/src/evo/common/__init__.py @@ -27,10 +27,15 @@ ) from .interfaces import IAuthorizer, ICache, IContext, IFeedback, ITransport from .service import BaseAPIClient +from .typed import BoundingBox as BoundingBox +from .typed import Point3 as Point3 +from .typed import Size3d as Size3d +from .typed import Size3i as Size3i __all__ = [ "APIConnector", "BaseAPIClient", + "BoundingBox", "DependencyStatus", "EmptyResponse", "Environment", @@ -45,10 +50,13 @@ "ITransport", "NoAuth", "Page", + "Point3", "RequestMethod", "ResourceMetadata", "ServiceHealth", "ServiceStatus", "ServiceUser", + "Size3d", + "Size3i", "StaticContext", ] diff --git a/packages/evo-sdk-common/src/evo/common/typed.py b/packages/evo-sdk-common/src/evo/common/typed.py new file mode 100644 index 00000000..94a2d305 --- /dev/null +++ b/packages/evo-sdk-common/src/evo/common/typed.py @@ -0,0 +1,85 @@ +# Copyright © 2026 Bentley Systems, Incorporated +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Common geometry types shared across Evo SDK packages. + +These types provide a lightweight, dependency-free representation of common +3D geometry primitives used by block models, grids, and other spatial objects. +""" + +from __future__ import annotations + +from typing import NamedTuple + +__all__ = [ + "BoundingBox", + "Point3", + "Size3d", + "Size3i", +] + + +class Point3(NamedTuple): + """A 3D point defined by X, Y, and Z coordinates.""" + + x: float + y: float + z: float + + +class Size3d(NamedTuple): + """A 3D size defined by dx, dy, and dz dimensions.""" + + dx: float + dy: float + dz: float + + +class Size3i(NamedTuple): + """A 3D size defined by nx, ny, and nz integer dimensions.""" + + nx: int + ny: int + nz: int + + @property + def total_size(self) -> int: + """The total size (number of elements) represented by this Size3i.""" + return self.nx * self.ny * self.nz + + +class BoundingBox(NamedTuple): + """An axis-aligned bounding box defined by minimum and maximum coordinates.""" + + x_min: float + x_max: float + y_min: float + y_max: float + z_min: float + z_max: float + + @classmethod + def from_origin_and_size(cls, origin: Point3, size: Size3i, cell_size: Size3d) -> BoundingBox: + """Create a bounding box from an origin point and grid dimensions. + + :param origin: The origin point of the grid. + :param size: The number of cells in each dimension. + :param cell_size: The size of each cell in each dimension. + :return: A BoundingBox enclosing the grid. + """ + return cls( + x_min=origin.x, + x_max=origin.x + size.nx * cell_size.dx, + y_min=origin.y, + y_max=origin.y + size.ny * cell_size.dy, + z_min=origin.z, + z_max=origin.z + size.nz * cell_size.dz, + ) diff --git a/packages/evo-widgets/pyproject.toml b/packages/evo-widgets/pyproject.toml index c0cea152..7abf51cb 100644 --- a/packages/evo-widgets/pyproject.toml +++ b/packages/evo-widgets/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "evo-widgets" description = "Widgets and presentation layer for Evo SDK - HTML rendering and IPython formatters for Jupyter notebooks" -version = "0.1.0" +version = "0.2.0" requires-python = ">=3.10" license-files = ["LICENSE.md"] dynamic = ["readme"] diff --git a/packages/evo-widgets/src/evo/widgets/__init__.py b/packages/evo-widgets/src/evo/widgets/__init__.py index b4944d93..5877ef22 100644 --- a/packages/evo-widgets/src/evo/widgets/__init__.py +++ b/packages/evo-widgets/src/evo/widgets/__init__.py @@ -33,8 +33,21 @@ from typing import TYPE_CHECKING -from .formatters import format_attributes_collection, format_base_object, format_variogram +from .formatters import ( + format_attributes_collection, + format_base_object, + format_block_model, + format_block_model_attributes, + format_block_model_version, + format_report, + format_report_result, + format_variogram, +) from .urls import ( + get_blocksync_base_url, + get_blocksync_block_model_url, + get_blocksync_block_model_url_from_environment, + get_blocksync_report_url, get_evo_base_url, get_hub_code, get_portal_url, @@ -53,7 +66,16 @@ __all__ = [ "format_attributes_collection", "format_base_object", + "format_block_model", + "format_block_model_attributes", + "format_block_model_version", + "format_report", + "format_report_result", "format_variogram", + "get_blocksync_base_url", + "get_blocksync_block_model_url", + "get_blocksync_block_model_url_from_environment", + "get_blocksync_report_url", "get_evo_base_url", "get_hub_code", "get_portal_url", @@ -101,6 +123,38 @@ def _register_formatters(ipython: InteractiveShell) -> None: format_attributes_collection, ) + # Register formatters for block model types + html_formatter.for_type_by_name( + "evo.blockmodels.data", + "Version", + format_block_model_version, + ) + + html_formatter.for_type_by_name( + "evo.blockmodels.typed.report", + "Report", + format_report, + ) + + html_formatter.for_type_by_name( + "evo.blockmodels.typed.report", + "ReportResult", + format_report_result, + ) + + # Register formatters for BlockModel from evo-objects + html_formatter.for_type_by_name( + "evo.objects.typed.block_model_ref", + "BlockModel", + format_block_model, + ) + + html_formatter.for_type_by_name( + "evo.objects.typed.block_model_ref", + "BlockModelAttributes", + format_block_model_attributes, + ) + def _unregister_formatters(ipython: InteractiveShell) -> None: """Unregister HTML formatters for Evo SDK types. diff --git a/packages/evo-widgets/src/evo/widgets/formatters.py b/packages/evo-widgets/src/evo/widgets/formatters.py index e303f536..7362d31e 100644 --- a/packages/evo-widgets/src/evo/widgets/formatters.py +++ b/packages/evo-widgets/src/evo/widgets/formatters.py @@ -26,19 +26,28 @@ build_table_row_vtop, build_title, ) -from .urls import get_portal_url_for_object, get_viewer_url_for_object +from .urls import get_blocksync_block_model_url_from_environment, get_portal_url_for_object, get_viewer_url_for_object __all__ = [ "format_attributes_collection", "format_base_object", + "format_block_model", + "format_block_model_attributes", + "format_block_model_version", + "format_report", + "format_report_result", "format_variogram", ] -def _get_base_metadata(obj: Any) -> tuple[str, list[tuple[str, str]] | None, list[tuple[str, str]]]: +def _get_base_metadata( + obj: Any, + extra_links: list[tuple[str, str]] | None = None, +) -> tuple[str, list[tuple[str, str]] | None, list[tuple[str, str]]]: """Extract common metadata from a geoscience object. :param obj: A typed geoscience object with `as_dict()` and `metadata` attributes. + :param extra_links: Optional additional links to include after Portal/Viewer links. :return: A tuple of (name, title_links, rows) where: - name: The object name - title_links: List of (label, url) tuples for Portal/Viewer links, or None @@ -56,8 +65,10 @@ def _get_base_metadata(obj: Any) -> tuple[str, list[tuple[str, str]] | None, lis portal_url = get_portal_url_for_object(obj) viewer_url = get_viewer_url_for_object(obj) title_links = [("Portal", portal_url), ("Viewer", viewer_url)] + if extra_links: + title_links.extend(extra_links) except (AttributeError, TypeError): - title_links = None + title_links = extra_links if extra_links else None # Build metadata rows rows: list[tuple[str, str]] = [ @@ -73,6 +84,31 @@ def _get_base_metadata(obj: Any) -> tuple[str, list[tuple[str, str]] | None, lis return name, title_links, rows +def _format_bounding_box(bbox: dict[str, Any]) -> str: + """Format a bounding box as a nested HTML table. + + :param bbox: Dictionary with min_x, max_x, min_y, max_y, min_z, max_z keys. + :return: HTML string for the bounding box table. + """ + bbox_rows = [ + ["X:", f"{bbox.get('min_x', 0):.2f}", f"{bbox.get('max_x', 0):.2f}"], + ["Y:", f"{bbox.get('min_y', 0):.2f}", f"{bbox.get('max_y', 0):.2f}"], + ["Z:", f"{bbox.get('min_z', 0):.2f}", f"{bbox.get('max_z', 0):.2f}"], + ] + return build_nested_table(["", "Min", "Max"], bbox_rows) + + +def _format_crs(crs: Any) -> str: + """Format a coordinate reference system. + + :param crs: CRS dict with epsg_code or ogc_wkt, or a string. + :return: Formatted CRS string. + """ + if isinstance(crs, dict): + return f"EPSG:{crs.get('epsg_code')}" if crs.get("epsg_code") else str(crs) + return str(crs) + + def _build_html_from_rows( name: str, title_links: list[tuple[str, str]] | None, @@ -120,18 +156,11 @@ def format_base_object(obj: Any) -> str: # Add bounding box if present (as nested table) if bbox := doc.get("bounding_box"): - bbox_rows = [ - ["X:", bbox.get("min_x", 0), bbox.get("max_x", 0)], - ["Y:", bbox.get("min_y", 0), bbox.get("max_y", 0)], - ["Z:", bbox.get("min_z", 0), bbox.get("max_z", 0)], - ] - bbox_table = build_nested_table(["", "Min", "Max"], bbox_rows) - rows.append(("Bounding box:", bbox_table)) + rows.append(("Bounding box:", _format_bounding_box(bbox))) # Add CRS if present if crs := doc.get("coordinate_reference_system"): - crs_str = f"EPSG:{crs.get('epsg_code')}" if isinstance(crs, dict) else str(crs) - rows.append(("CRS:", crs_str)) + rows.append(("CRS:", _format_crs(crs))) # Build datasets section - add as rows to the main table sub_models = getattr(obj, "_sub_models", []) @@ -263,3 +292,272 @@ def format_variogram(obj: Any) -> str: ) return _build_html_from_rows(name, title_links, rows, extra_content) + + +def format_block_model_version(obj: Any) -> str: + """Format a block model Version object as HTML. + + This formatter renders a block model version with its metadata, + bounding box, and column information as a styled HTML table. + + :param obj: A Version object from evo.blockmodels.data. + :return: HTML string representation. + """ + # Build columns table + col_rows = [[col.title, col.data_type.value, col.unit_id or "-"] for col in obj.columns] + columns_html = build_nested_table(["Title", "Type", "Unit"], col_rows) + + # Build bbox table + bbox_html = "-" + if obj.bbox: + bbox_rows = [ + ["i", obj.bbox.i_minmax.min, obj.bbox.i_minmax.max], + ["j", obj.bbox.j_minmax.min, obj.bbox.j_minmax.max], + ["k", obj.bbox.k_minmax.min, obj.bbox.k_minmax.max], + ] + bbox_html = build_nested_table(["Axis", "Min", "Max"], bbox_rows) + + # Build table rows + rows_html = "".join( + [ + build_table_row("Version ID", str(obj.version_id)), + build_table_row("Version UUID", str(obj.version_uuid)), + build_table_row("Block Model UUID", str(obj.bm_uuid)), + build_table_row("Parent Version", str(obj.parent_version_id) if obj.parent_version_id else "-"), + build_table_row("Base Version", str(obj.base_version_id) if obj.base_version_id else "-"), + build_table_row("Created At", obj.created_at.strftime("%Y-%m-%d %H:%M:%S")), + build_table_row("Created By", obj.created_by.name or obj.created_by.email or str(obj.created_by.id)), + build_table_row("Comment", obj.comment if obj.comment else "-"), + build_table_row_vtop("Bounding Box", bbox_html), + build_table_row_vtop("Columns", columns_html), + ] + ) + + html = f"""{STYLESHEET} +
+{build_title("📦 Block Model Version")} + +{rows_html} +
+
+""" + return html + + +def format_report_result(obj: Any) -> str: + """Format a ReportResult object as HTML. + + This formatter renders a block model report result with its data + as a styled HTML table. + + :param obj: A ReportResult object from evo.blockmodels.typed.report. + :return: HTML string representation. + """ + df = obj.to_dataframe() + + # Build the result table with alternating row colors + headers = list(df.columns) + header_html = "".join([f"{h}" for h in headers]) + + rows_html = [] + for i, (_, row) in enumerate(df.iterrows()): + row_class = 'class="alt-row"' if i % 2 == 1 else "" + cells = "".join([f"{v if v is not None and v == v else ''}" for v in row]) + rows_html.append(f"{cells}") + + subtitle = f'
Created: {obj.created_at.strftime("%Y-%m-%d %H:%M:%S")} | Rows: {len(df)}
' + + html = f"""{STYLESHEET} +
+{build_title("📊 Report Result (Version " + str(obj.version_id) + ")")} +{subtitle} + + {header_html} + {"".join(rows_html)} +
+
+""" + return html + + +def format_report(obj: Any) -> str: + """Format a Report object as HTML. + + This formatter renders a block model report specification with its + columns, categories, and BlockSync link as a styled HTML table. + + :param obj: A Report object from evo.blockmodels.typed.report. + :return: HTML string representation. + """ + from .urls import get_blocksync_report_url, get_hub_code + + # Get environment info for BlockSync URL + environment = obj._context.get_environment() + hub_code = get_hub_code(environment.hub_url) + blocksync_url = get_blocksync_report_url( + org_id=environment.org_id, + hub_code=hub_code, + workspace_id=environment.workspace_id, + block_model_id=obj._block_model_uuid, + report_id=obj.id, + ) + + # Build column info table + columns_html = "" + if obj._specification.columns: + col_rows = [] + for i, col in enumerate(obj._specification.columns): + row_class = 'class="alt-row"' if i % 2 == 1 else "" + col_rows.append( + f"{col.label}{col.aggregation}{col.output_unit_id}" + ) + columns_html = f""" +
Columns:
+ + + {"".join(col_rows)} +
LabelAggregationUnit
+ """ + + # Build category info table + categories_html = "" + if obj._specification.categories: + cat_rows = [] + for i, cat in enumerate(obj._specification.categories): + row_class = 'class="alt-row"' if i % 2 == 1 else "" + values_str = ", ".join(cat.values) if cat.values else "(all)" + cat_rows.append(f"{cat.label}{values_str}") + categories_html = f""" +
Categories:
+ + + {"".join(cat_rows)} +
LabelValues
+ """ + + # Build main info table rows + block_model_display = ( + f"{obj._block_model_name} ({obj._block_model_uuid})" if obj._block_model_name else str(obj._block_model_uuid) + ) + + rows: list[tuple[str, str]] = [ + ("Report ID:", str(obj.id)), + ("Block Model:", block_model_display), + ("Revision:", str(obj.revision)), + ] + + # Add last run if available + if hasattr(obj._specification, "last_result_created_at") and obj._specification.last_result_created_at: + rows.append(("Last run:", obj._specification.last_result_created_at.strftime("%Y-%m-%d %H:%M:%S"))) + + # Build table rows HTML + table_rows_html = "".join([build_table_row(label, value) for label, value in rows]) + + html = f"""{STYLESHEET} +
+{build_title(f"📊 {obj.name}", [("BlockSync", blocksync_url)])} + +{table_rows_html} +
+{categories_html} +{columns_html} +
+""" + return html + + +def format_block_model_attributes(obj: Any) -> str: + """Format a BlockModelAttributes collection as HTML. + + This formatter renders a collection of block model attributes as a styled table + showing name, type and unit for each attribute. + + :param obj: A BlockModelAttributes object that is iterable. + :return: HTML string representation. + """ + if len(obj) == 0: + return f'{STYLESHEET}
No attributes available.
' + + headers = ["Name", "Type", "Unit"] + rows = [[attr.name, attr.attribute_type, attr.unit or ""] for attr in obj] + table_html = build_nested_table(headers, rows) + return f'{STYLESHEET}
{table_html}
' + + +def format_block_model(obj: Any) -> str: + """Format a BlockModel (from evo.objects.typed) as HTML. + + This formatter renders a block model reference with its metadata, geometry, + bounding box, and attributes as a styled HTML table with Portal/Viewer/BlockSync links. + + :param obj: A BlockModel object from evo.objects.typed.block_model_ref. + :return: HTML string representation. + """ + doc = obj.as_dict() + + # Build BlockSync link + try: + blocksync_url = get_blocksync_block_model_url_from_environment( + environment=obj._obj.metadata.environment, + block_model_id=obj.block_model_uuid, + ) + extra_links = [("BlockSync", blocksync_url)] + except (AttributeError, TypeError): + extra_links = None + + # Get common metadata with BlockSync link + name, title_links, rows = _get_base_metadata(obj, extra_links=extra_links) + + # Add Block Model UUID + rows.append(("Block Model UUID:", str(obj.block_model_uuid))) + + # Add geometry info + geom = obj.geometry + geom_rows = [ + ["Origin:", f"({geom.origin.x:.2f}, {geom.origin.y:.2f}, {geom.origin.z:.2f})"], + ["N Blocks:", f"({geom.n_blocks.nx}, {geom.n_blocks.ny}, {geom.n_blocks.nz})"], + [ + "Block Size:", + f"({geom.block_size.dx:.2f}, {geom.block_size.dy:.2f}, {geom.block_size.dz:.2f})", + ], + ] + if geom.rotation: + geom_rows.append( + [ + "Rotation:", + f"({geom.rotation[0]:.2f}, {geom.rotation[1]:.2f}, {geom.rotation[2]:.2f})", + ] + ) + geom_table = build_nested_table(["Property", "Value"], geom_rows) + rows.append(("Geometry:", geom_table)) + + # Add bounding box if present + if bbox := doc.get("bounding_box"): + rows.append(("Bounding Box:", _format_bounding_box(bbox))) + + # Add CRS if present + if crs := doc.get("coordinate_reference_system"): + rows.append(("CRS:", _format_crs(crs))) + + # Build the table rows + table_rows = [] + for label, value in rows: + if label in ("Bounding Box:", "Geometry:"): + table_rows.append(build_table_row_vtop(label, value)) + else: + table_rows.append(build_table_row(label, value)) + + html = STYLESHEET + html += '
' + html += build_title(name, title_links) + html += f"{''.join(table_rows)}
" + + # Build attributes section + attrs = obj.attributes + if attrs and len(attrs) > 0: + attr_rows = [[attr.name, attr.attribute_type, attr.unit or ""] for attr in attrs] + attrs_table = build_nested_table(["Name", "Type", "Unit"], attr_rows) + html += f'
Attributes ({len(attrs)}):
{attrs_table}' + + html += "
" + return html diff --git a/packages/evo-widgets/src/evo/widgets/urls.py b/packages/evo-widgets/src/evo/widgets/urls.py index 2a145c7a..26a8a039 100644 --- a/packages/evo-widgets/src/evo/widgets/urls.py +++ b/packages/evo-widgets/src/evo/widgets/urls.py @@ -18,13 +18,19 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +from uuid import UUID from evo.objects import ObjectReference if TYPE_CHECKING: + from evo.common import Environment from evo.common.interfaces import IContext __all__ = [ + "get_blocksync_base_url", + "get_blocksync_block_model_url", + "get_blocksync_block_model_url_from_environment", + "get_blocksync_report_url", "get_evo_base_url", "get_hub_code", "get_portal_url", @@ -47,6 +53,78 @@ def get_evo_base_url(hub_url: str) -> str: return "https://evo.seequent.com" +def get_blocksync_base_url(hub_url: str) -> str: + """Determine the BlockSync base URL from an API hub URL. + + :param hub_url: The hub URL (e.g., "https://350mt.api.seequent.com"). + :return: The BlockSync base URL (e.g., "https://blocksync.seequent.com"). + """ + return "https://blocksync.seequent.com" + + +def get_blocksync_block_model_url( + org_id: str | UUID, + workspace_id: str | UUID, + block_model_id: str | UUID, + hub_url: str, +) -> str: + """Generate the BlockSync Portal URL for a block model. + + Uses the format: /{org_id}/redirect?ws={workspace_id}&bm={block_model_id} + + :param org_id: The organization ID. + :param workspace_id: The workspace ID. + :param block_model_id: The block model ID. + :param hub_url: The hub URL to determine the environment. + :return: The complete BlockSync block model URL. + """ + base_url = get_blocksync_base_url(hub_url) + return f"{base_url}/{str(org_id).lower()}/redirect?ws={str(workspace_id).lower()}&bm={str(block_model_id).lower()}" + + +def get_blocksync_block_model_url_from_environment( + environment: "Environment", + block_model_id: str | UUID, +) -> str: + """Generate the BlockSync Portal URL from an Environment object. + + :param environment: The environment containing org_id, workspace_id, and hub_url. + :param block_model_id: The block model ID. + :return: The complete BlockSync block model URL. + """ + return get_blocksync_block_model_url( + org_id=environment.org_id, + workspace_id=environment.workspace_id, + block_model_id=block_model_id, + hub_url=environment.hub_url, + ) + + +def get_blocksync_report_url( + org_id: str | UUID, + hub_code: str, + workspace_id: str | UUID, + block_model_id: str | UUID, + report_id: str | UUID, + result_id: str | UUID | None = None, +) -> str: + """Generate the BlockSync URL for a block model report. + + :param org_id: The organization ID. + :param hub_code: The hub code (e.g., "350mt"). + :param workspace_id: The workspace ID. + :param block_model_id: The block model ID. + :param report_id: The report specification ID. + :param result_id: Optional result ID to link to a specific result. + :return: The complete BlockSync report URL. + """ + base_url = get_blocksync_base_url("") + url = f"{base_url}/{org_id}/{hub_code}/{workspace_id}/blockmodel/{block_model_id}/reports/{report_id}" + if result_id: + url += f"?result_id={result_id}" + return url + + def get_hub_code(hub_url: str) -> str: """Extract the hub code from a hub URL. diff --git a/packages/evo-widgets/tests/test_formatters.py b/packages/evo-widgets/tests/test_formatters.py index 9222366e..7675510f 100644 --- a/packages/evo-widgets/tests/test_formatters.py +++ b/packages/evo-widgets/tests/test_formatters.py @@ -12,9 +12,135 @@ """Tests for evo.widgets.formatters module.""" import unittest +from datetime import datetime from unittest.mock import MagicMock +from uuid import UUID + +from evo.widgets.formatters import ( + _format_bounding_box, + _format_crs, + _get_base_metadata, + format_attributes_collection, + format_base_object, + format_block_model, + format_block_model_attributes, + format_block_model_version, + format_report, + format_report_result, + format_variogram, +) + + +class TestHelperFunctions(unittest.TestCase): + """Tests for the helper functions.""" + + def test_format_bounding_box(self): + """Test formatting a bounding box as HTML table.""" + bbox = { + "min_x": 0.0, + "max_x": 100.5, + "min_y": 10.0, + "max_y": 200.75, + "min_z": -50.0, + "max_z": 50.25, + } + + html = _format_bounding_box(bbox) + + self.assertIn("Min", html) + self.assertIn("Max", html) + self.assertIn("0.00", html) + self.assertIn("100.50", html) + self.assertIn("10.00", html) + self.assertIn("200.75", html) + self.assertIn("-50.00", html) + self.assertIn("50.25", html) + + def test_format_crs_with_epsg_code(self): + """Test formatting CRS with EPSG code.""" + crs = {"epsg_code": 4326} + result = _format_crs(crs) + self.assertEqual(result, "EPSG:4326") + + def test_format_crs_with_string(self): + """Test formatting CRS as string.""" + crs = "WGS84" + result = _format_crs(crs) + self.assertEqual(result, "WGS84") + + def test_format_crs_with_dict_no_epsg(self): + """Test formatting CRS dict without EPSG code.""" + crs = {"ogc_wkt": "some wkt string"} + result = _format_crs(crs) + self.assertIn("ogc_wkt", result) + + def test_get_base_metadata_basic(self): + """Test extracting base metadata from an object.""" + obj = MagicMock() + obj.as_dict.return_value = { + "name": "Test Object", + "schema": "test-schema", + "uuid": "12345-abcd", + } + obj.metadata = MagicMock() + obj.metadata.environment = MagicMock() + obj.metadata.environment.org_id = "org-123" + obj.metadata.environment.workspace_id = "ws-456" + obj.metadata.environment.hub_url = "https://test.api.seequent.com" + obj.metadata.id = "12345-abcd" + + name, title_links, rows = _get_base_metadata(obj) + + self.assertEqual(name, "Test Object") + self.assertIsNotNone(title_links) + self.assertEqual(len(title_links), 2) # Portal and Viewer + self.assertEqual(rows[0], ("Object ID:", "12345-abcd")) + self.assertEqual(rows[1], ("Schema:", "test-schema")) + + def test_get_base_metadata_with_extra_links(self): + """Test extracting base metadata with extra links.""" + obj = MagicMock() + obj.as_dict.return_value = { + "name": "Test Object", + "schema": "test-schema", + "uuid": "12345-abcd", + } + obj.metadata = MagicMock() + obj.metadata.environment = MagicMock() + obj.metadata.environment.org_id = "org-123" + obj.metadata.environment.workspace_id = "ws-456" + obj.metadata.environment.hub_url = "https://test.api.seequent.com" + obj.metadata.id = "12345-abcd" + + extra_links = [("BlockSync", "https://blocksync.seequent.com/test")] + name, title_links, rows = _get_base_metadata(obj, extra_links=extra_links) + + self.assertEqual(len(title_links), 3) # Portal, Viewer, and BlockSync + self.assertEqual(title_links[2], ("BlockSync", "https://blocksync.seequent.com/test")) + + def test_get_base_metadata_with_tags(self): + """Test extracting base metadata with tags.""" + obj = MagicMock() + obj.as_dict.return_value = { + "name": "Test Object", + "schema": "test-schema", + "uuid": "12345-abcd", + "tags": {"key1": "value1", "key2": "value2"}, + } + obj.metadata = MagicMock() + obj.metadata.environment = MagicMock() + obj.metadata.environment.org_id = "org-123" + obj.metadata.environment.workspace_id = "ws-456" + obj.metadata.environment.hub_url = "https://test.api.seequent.com" + obj.metadata.id = "12345-abcd" + + name, title_links, rows = _get_base_metadata(obj) -from evo.widgets.formatters import format_attributes_collection, format_base_object, format_variogram + # Should have 3 rows: Object ID, Schema, Tags + self.assertEqual(len(rows), 3) + self.assertEqual(rows[2][0], "Tags:") + self.assertIn("key1", rows[2][1]) + self.assertIn("value1", rows[2][1]) class TestFormatBaseObject(unittest.TestCase): @@ -347,5 +473,467 @@ def test_formats_variogram_structure_ranges(self): self.assertIn("90.0", html) +class TestFormatBlockModelVersion(unittest.TestCase): + """Tests for the format_block_model_version function.""" + + def _create_mock_version(self, **kwargs): + """Create a mock Version object.""" + defaults = { + "version_id": 1, + "version_uuid": UUID("12345678-1234-1234-1234-123456789abc"), + "bm_uuid": UUID("abcd1234-1234-1234-1234-123456789abc"), + "parent_version_id": None, + "base_version_id": None, + "created_at": datetime(2025, 1, 15, 10, 30, 0), + "comment": "Initial version", + "columns": [], + } + defaults.update(kwargs) + + obj = MagicMock() + obj.version_id = defaults["version_id"] + obj.version_uuid = defaults["version_uuid"] + obj.bm_uuid = defaults["bm_uuid"] + obj.parent_version_id = defaults["parent_version_id"] + obj.base_version_id = defaults["base_version_id"] + obj.created_at = defaults["created_at"] + obj.comment = defaults["comment"] + obj.columns = defaults["columns"] + obj.bbox = None + + # Create mock user + created_by = MagicMock() + created_by.name = "Test User" + created_by.email = "test@example.com" + created_by.id = "user-123" + obj.created_by = created_by + + return obj + + def test_formats_version_basic_info(self): + """Test formatting a version with basic information.""" + obj = self._create_mock_version() + + html = format_block_model_version(obj) + + self.assertIn("Version ID", html) + self.assertIn("1", html) + self.assertIn("Version UUID", html) + self.assertIn("Block Model UUID", html) + self.assertIn("Created At", html) + self.assertIn("2025-01-15", html) + self.assertIn("Created By", html) + self.assertIn("Test User", html) + self.assertIn("Comment", html) + self.assertIn("Initial version", html) + + def test_formats_version_with_columns(self): + """Test formatting a version with columns.""" + col1 = MagicMock() + col1.title = "Au" + col1.data_type = MagicMock() + col1.data_type.value = "Float64" + col1.unit_id = "g/t" + + col2 = MagicMock() + col2.title = "density" + col2.data_type = MagicMock() + col2.data_type.value = "Float64" + col2.unit_id = "t/m3" + + obj = self._create_mock_version(columns=[col1, col2]) + + html = format_block_model_version(obj) + + self.assertIn("Columns", html) + self.assertIn("Au", html) + self.assertIn("Float64", html) + self.assertIn("g/t", html) + self.assertIn("density", html) + self.assertIn("t/m3", html) + + def test_formats_version_with_bbox(self): + """Test formatting a version with bounding box.""" + bbox = MagicMock() + bbox.i_minmax = MagicMock() + bbox.i_minmax.min = 0 + bbox.i_minmax.max = 10 + bbox.j_minmax = MagicMock() + bbox.j_minmax.min = 0 + bbox.j_minmax.max = 20 + bbox.k_minmax = MagicMock() + bbox.k_minmax.min = 0 + bbox.k_minmax.max = 5 + + obj = self._create_mock_version() + obj.bbox = bbox + + html = format_block_model_version(obj) + + self.assertIn("Bounding Box", html) + self.assertIn("10", html) + self.assertIn("20", html) + + +class TestFormatBlockModel(unittest.TestCase): + """Tests for the format_block_model function.""" + + def _create_mock_block_model(self, **kwargs): + """Create a mock BlockModel object.""" + defaults = { + "name": "Test Block Model", + "schema": "objects/block-model/v1.0.0", + "uuid": "12345-abcd", + "block_model_uuid": UUID("abcd1234-1234-1234-1234-123456789abc"), + "bounding_box": None, + "coordinate_reference_system": None, + "tags": None, + } + defaults.update(kwargs) + + obj = MagicMock() + obj.as_dict.return_value = { + "name": defaults["name"], + "schema": defaults["schema"], + "uuid": defaults["uuid"], + } + if defaults["bounding_box"]: + obj.as_dict.return_value["bounding_box"] = defaults["bounding_box"] + if defaults["coordinate_reference_system"]: + obj.as_dict.return_value["coordinate_reference_system"] = defaults["coordinate_reference_system"] + if defaults["tags"]: + obj.as_dict.return_value["tags"] = defaults["tags"] + + obj.block_model_uuid = defaults["block_model_uuid"] + + # Set up geometry + geometry = MagicMock() + origin = MagicMock() + origin.x = 0.0 + origin.y = 0.0 + origin.z = 0.0 + geometry.origin = origin + + n_blocks = MagicMock() + n_blocks.nx = 10 + n_blocks.ny = 20 + n_blocks.nz = 5 + geometry.n_blocks = n_blocks + + block_size = MagicMock() + block_size.dx = 2.5 + block_size.dy = 5.0 + block_size.dz = 5.0 + geometry.block_size = block_size + + geometry.rotation = None + obj.geometry = geometry + + # Set up attributes + obj.attributes = [] + + # Set up inner _obj for metadata + inner_obj = MagicMock() + inner_obj.metadata = MagicMock() + inner_obj.metadata.environment = MagicMock() + inner_obj.metadata.environment.org_id = "org-123" + inner_obj.metadata.environment.workspace_id = "ws-456" + inner_obj.metadata.environment.hub_url = "https://test.api.seequent.com" + inner_obj.metadata.id = defaults["uuid"] + obj._obj = inner_obj + + # Set up metadata on obj for URL generation + obj.metadata = inner_obj.metadata + + return obj + + def test_formats_block_model_basic_info(self): + """Test formatting a block model with basic information.""" + obj = self._create_mock_block_model() + + html = format_block_model(obj) + + self.assertIn("Test Block Model", html) + self.assertIn("objects/block-model/v1.0.0", html) + self.assertIn("12345-abcd", html) + self.assertIn("Block Model UUID:", html) + + def test_formats_block_model_geometry(self): + """Test formatting a block model with geometry information.""" + obj = self._create_mock_block_model() + + html = format_block_model(obj) + + self.assertIn("Geometry:", html) + self.assertIn("Origin:", html) + self.assertIn("N Blocks:", html) + self.assertIn("Block Size:", html) + self.assertIn("(10, 20, 5)", html) # n_blocks + self.assertIn("2.50", html) # block_size.dx + + def test_formats_block_model_with_rotation(self): + """Test formatting a block model with rotation.""" + obj = self._create_mock_block_model() + obj.geometry.rotation = (45.0, 30.0, 15.0) + + html = format_block_model(obj) + + self.assertIn("Rotation:", html) + self.assertIn("45.00", html) + self.assertIn("30.00", html) + self.assertIn("15.00", html) + + def test_formats_block_model_with_bounding_box(self): + """Test formatting a block model with bounding box.""" + obj = self._create_mock_block_model( + bounding_box={ + "min_x": 0.0, + "max_x": 25.0, + "min_y": 0.0, + "max_y": 100.0, + "min_z": 0.0, + "max_z": 25.0, + } + ) + + html = format_block_model(obj) + + self.assertIn("Bounding Box:", html) + self.assertIn("25.00", html) + self.assertIn("100.00", html) + + def test_formats_block_model_with_crs(self): + """Test formatting a block model with CRS.""" + obj = self._create_mock_block_model(coordinate_reference_system={"epsg_code": 28354}) + + html = format_block_model(obj) + + self.assertIn("CRS:", html) + self.assertIn("EPSG:28354", html) + + def test_formats_block_model_with_attributes(self): + """Test formatting a block model with attributes.""" + attr1 = MagicMock() + attr1.name = "Au" + attr1.attribute_type = "Float64" + attr1.unit = "g/t" + + attr2 = MagicMock() + attr2.name = "density" + attr2.attribute_type = "Float64" + attr2.unit = "t/m3" + + obj = self._create_mock_block_model() + obj.attributes = [attr1, attr2] + + html = format_block_model(obj) + + self.assertIn("Attributes (2):", html) + self.assertIn("Au", html) + self.assertIn("Float64", html) + self.assertIn("g/t", html) + self.assertIn("density", html) + self.assertIn("t/m3", html) + + +class TestFormatBlockModelAttributes(unittest.TestCase): + """Tests for the format_block_model_attributes function.""" + + def test_formats_empty_attributes(self): + """Test formatting empty block model attributes collection.""" + obj = MagicMock() + obj.__len__ = MagicMock(return_value=0) + + html = format_block_model_attributes(obj) + + self.assertIn("No attributes available", html) + + def test_formats_attributes_collection(self): + """Test formatting a collection of block model attributes.""" + attr1 = MagicMock() + attr1.name = "Au" + attr1.attribute_type = "Float64" + attr1.unit = "g/t" + + attr2 = MagicMock() + attr2.name = "density" + attr2.attribute_type = "Float64" + attr2.unit = None + + obj = MagicMock() + obj.__len__ = MagicMock(return_value=2) + obj.__iter__ = MagicMock(return_value=iter([attr1, attr2])) + + html = format_block_model_attributes(obj) + + self.assertIn("Name", html) + self.assertIn("Type", html) + self.assertIn("Unit", html) + self.assertIn("Au", html) + self.assertIn("Float64", html) + self.assertIn("g/t", html) + self.assertIn("density", html) + + +class TestFormatReport(unittest.TestCase): + """Tests for the format_report function.""" + + def _create_mock_report(self, **kwargs): + """Create a mock Report object.""" + defaults = { + "id": UUID("12345678-1234-1234-1234-123456789abc"), + "name": "Test Report", + "revision": 1, + "block_model_uuid": UUID("abcd1234-1234-1234-1234-123456789abc"), + "block_model_name": "Test Block Model", + "columns": [], + "categories": [], + "last_result_created_at": None, + } + defaults.update(kwargs) + + obj = MagicMock() + obj.id = defaults["id"] + obj.name = defaults["name"] + obj.revision = defaults["revision"] + obj._block_model_uuid = defaults["block_model_uuid"] + obj._block_model_name = defaults["block_model_name"] + + # Set up specification + spec = MagicMock() + spec.columns = defaults["columns"] + spec.categories = defaults["categories"] + spec.last_result_created_at = defaults["last_result_created_at"] + obj._specification = spec + + # Set up context for URL generation + context = MagicMock() + env = MagicMock() + env.org_id = "org-123" + env.workspace_id = "ws-456" + env.hub_url = "https://test.api.seequent.com" + context.get_environment.return_value = env + obj._context = context + + return obj + + def test_formats_report_basic_info(self): + """Test formatting a report with basic information.""" + obj = self._create_mock_report() + + html = format_report(obj) + + self.assertIn("Test Report", html) + self.assertIn("Report ID:", html) + self.assertIn("Block Model:", html) + self.assertIn("Test Block Model", html) + self.assertIn("Revision:", html) + self.assertIn("1", html) + + def test_formats_report_with_columns(self): + """Test formatting a report with column specifications.""" + col1 = MagicMock() + col1.label = "Au Grade" + col1.aggregation = "MASS_AVERAGE" + col1.output_unit_id = "g/t" + + col2 = MagicMock() + col2.label = "Tonnage" + col2.aggregation = "SUM" + col2.output_unit_id = "t" + + obj = self._create_mock_report(columns=[col1, col2]) + + html = format_report(obj) + + self.assertIn("Columns:", html) + self.assertIn("Au Grade", html) + self.assertIn("MASS_AVERAGE", html) + self.assertIn("g/t", html) + self.assertIn("Tonnage", html) + self.assertIn("SUM", html) + + def test_formats_report_with_categories(self): + """Test formatting a report with category specifications.""" + cat1 = MagicMock() + cat1.label = "Domain" + cat1.values = ["ore", "waste"] + + cat2 = MagicMock() + cat2.label = "Rock Type" + cat2.values = None + + obj = self._create_mock_report(categories=[cat1, cat2]) + + html = format_report(obj) + + self.assertIn("Categories:", html) + self.assertIn("Domain", html) + self.assertIn("ore", html) + self.assertIn("waste", html) + self.assertIn("Rock Type", html) + self.assertIn("(all)", html) + + def test_formats_report_with_last_run(self): + """Test formatting a report with last run timestamp.""" + obj = self._create_mock_report(last_result_created_at=datetime(2025, 1, 15, 10, 30, 0)) + + html = format_report(obj) + + self.assertIn("Last run:", html) + self.assertIn("2025-01-15", html) + + +class TestFormatReportResult(unittest.TestCase): + """Tests for the format_report_result function.""" + + def _create_mock_report_result(self, **kwargs): + """Create a mock ReportResult object.""" + import pandas as pd + + defaults = { + "version_id": 1, + "created_at": datetime(2025, 1, 15, 10, 30, 0), + "dataframe": pd.DataFrame( + {"cutoff": [0.0, 0.5], "Domain": ["ore", "waste"], "Tonnage": [1000.0, 500.0], "Au Grade": [2.5, 1.2]} + ), + } + defaults.update(kwargs) + + obj = MagicMock() + obj.version_id = defaults["version_id"] + obj.created_at = defaults["created_at"] + obj.to_dataframe.return_value = defaults["dataframe"] + + return obj + + def test_formats_report_result_basic_info(self): + """Test formatting a report result with basic information.""" + obj = self._create_mock_report_result() + + html = format_report_result(obj) + + self.assertIn("Report Result", html) + self.assertIn("Version 1", html) + self.assertIn("Created:", html) + self.assertIn("2025-01-15", html) + self.assertIn("Rows: 2", html) + + def test_formats_report_result_table(self): + """Test formatting a report result with data table.""" + obj = self._create_mock_report_result() + + html = format_report_result(obj) + + self.assertIn("cutoff", html) + self.assertIn("Domain", html) + self.assertIn("Tonnage", html) + self.assertIn("Au Grade", html) + self.assertIn("ore", html) + self.assertIn("waste", html) + self.assertIn("1000", html) + self.assertIn("2.5", html) + + if __name__ == "__main__": unittest.main() diff --git a/packages/evo-widgets/tests/test_urls.py b/packages/evo-widgets/tests/test_urls.py index 4f705338..8e8246c3 100644 --- a/packages/evo-widgets/tests/test_urls.py +++ b/packages/evo-widgets/tests/test_urls.py @@ -13,8 +13,12 @@ import unittest from unittest.mock import MagicMock +from uuid import UUID from evo.widgets.urls import ( + get_blocksync_base_url, + get_blocksync_block_model_url, + get_blocksync_report_url, get_evo_base_url, get_hub_code, get_portal_url, @@ -264,5 +268,102 @@ def test_raises_on_unsupported_object_type(self): self.assertIn("Cannot extract object ID", str(ctx.exception)) +class TestGetBlocksyncBaseUrl(unittest.TestCase): + """Tests for get_blocksync_base_url function.""" + + def test_production_environment(self): + """Production environment hub URL returns production BlockSync URL.""" + hub_url = "https://350mt.api.seequent.com" + result = get_blocksync_base_url(hub_url) + self.assertEqual(result, "https://blocksync.seequent.com") + + def test_any_environment_returns_production(self): + """Any hub URL returns production BlockSync URL (no environment detection).""" + hub_url = "https://mining.api.seequent.com" + result = get_blocksync_base_url(hub_url) + self.assertEqual(result, "https://blocksync.seequent.com") + + +class TestGetBlocksyncBlockModelUrl(unittest.TestCase): + """Tests for get_blocksync_block_model_url function.""" + + def test_generates_correct_url_with_strings(self): + """BlockSync block model URL is generated correctly with string IDs.""" + result = get_blocksync_block_model_url( + org_id="org-123", + workspace_id="ws-456", + block_model_id="bm-789", + hub_url="https://350mt.api.seequent.com", + ) + self.assertEqual( + result, + "https://blocksync.seequent.com/org-123/redirect?ws=ws-456&bm=bm-789", + ) + + def test_generates_correct_url_with_uuids(self): + """BlockSync block model URL is generated correctly with UUID objects.""" + org_id = UUID("829e6621-0ab6-4d7d-96bb-2bb5b407a5fe") + workspace_id = UUID("783b6eef-01b9-42a7-aaf4-35e153e6fcbe") + block_model_id = UUID("9100d7dc-44e9-4e61-b427-159635dea22f") + + result = get_blocksync_block_model_url( + org_id=org_id, + workspace_id=workspace_id, + block_model_id=block_model_id, + hub_url="https://350mt.api.seequent.com", + ) + self.assertEqual( + result, + "https://blocksync.seequent.com/829e6621-0ab6-4d7d-96bb-2bb5b407a5fe" + "/redirect?ws=783b6eef-01b9-42a7-aaf4-35e153e6fcbe&bm=9100d7dc-44e9-4e61-b427-159635dea22f", + ) + + def test_lowercase_conversion(self): + """IDs are converted to lowercase in the URL.""" + result = get_blocksync_block_model_url( + org_id="ORG-ABC", + workspace_id="WS-DEF", + block_model_id="BM-GHI", + hub_url="https://350mt.api.seequent.com", + ) + self.assertEqual( + result, + "https://blocksync.seequent.com/org-abc/redirect?ws=ws-def&bm=bm-ghi", + ) + + +class TestGetBlocksyncReportUrl(unittest.TestCase): + """Tests for get_blocksync_report_url function.""" + + def test_generates_report_url(self): + """BlockSync report URL is generated correctly.""" + result = get_blocksync_report_url( + org_id="org-123", + hub_code="350mt", + workspace_id="ws-456", + block_model_id="bm-789", + report_id="report-abc", + ) + self.assertEqual( + result, + "https://blocksync.seequent.com/org-123/350mt/ws-456/blockmodel/bm-789/reports/report-abc", + ) + + def test_generates_report_url_with_result_id(self): + """BlockSync report URL includes result_id when provided.""" + result = get_blocksync_report_url( + org_id="org-123", + hub_code="350mt", + workspace_id="ws-456", + block_model_id="bm-789", + report_id="report-abc", + result_id="result-xyz", + ) + self.assertEqual( + result, + "https://blocksync.seequent.com/org-123/350mt/ws-456/blockmodel/bm-789/reports/report-abc?result_id=result-xyz", + ) + + if __name__ == "__main__": unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 47ef79a0..626ae14a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ description = "Python SDK for using Seequent Evo" requires-python = ">=3.10" dependencies = [ "evo-sdk-common[aiohttp,notebooks,jmespath]>=0.5.12", - "evo-widgets>=0.1.0", - "evo-blockmodels[aiohttp,notebooks,pyarrow]>=0.1.0", - "evo-objects[aiohttp,notebooks,utils]>=0.3.3", + "evo-widgets>=0.2.0", + "evo-blockmodels[aiohttp,notebooks,pyarrow]>=0.2.0", + "evo-objects[aiohttp,notebooks,utils]>=0.4.0", "evo-files[aiohttp,notebooks]>=0.2.3", "evo-colormaps[aiohttp,notebooks]>=0.0.2", "evo-compute[aiohttp,notebooks]>=0.0.1rc2", diff --git a/uv.lock b/uv.lock index f3c628d4..32598200 100644 --- a/uv.lock +++ b/uv.lock @@ -739,7 +739,7 @@ wheels = [ [[package]] name = "evo-blockmodels" -version = "0.1.1" +version = "0.2.0" source = { editable = "packages/evo-blockmodels" } dependencies = [ { name = "evo-sdk-common" }, @@ -754,6 +754,7 @@ notebooks = [ { name = "evo-sdk-common", extra = ["notebooks"] }, ] pyarrow = [ + { name = "pandas" }, { name = "pyarrow" }, ] @@ -781,6 +782,7 @@ requires-dist = [ { name = "evo-sdk-common", editable = "packages/evo-sdk-common" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'aiohttp'", editable = "packages/evo-sdk-common" }, { name = "evo-sdk-common", extras = ["notebooks"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, + { name = "pandas", marker = "extra == 'pyarrow'", specifier = ">=2.0.0" }, { name = "pyarrow", marker = "extra == 'pyarrow'", specifier = ">=19.0.0" }, { name = "pydantic", specifier = ">=2,<3" }, ] @@ -993,7 +995,7 @@ test = [ [[package]] name = "evo-objects" -version = "0.3.3" +version = "0.4.0" source = { editable = "packages/evo-objects" } dependencies = [ { name = "evo-sdk-common", extra = ["jmespath"] }, @@ -1004,6 +1006,9 @@ dependencies = [ aiohttp = [ { name = "evo-sdk-common", extra = ["aiohttp"] }, ] +blockmodels = [ + { name = "evo-blockmodels", extra = ["pyarrow"] }, +] notebooks = [ { name = "evo-sdk-common", extra = ["notebooks"] }, ] @@ -1019,7 +1024,7 @@ utils = [ dev = [ { name = "bumpver" }, { name = "coverage", extra = ["toml"] }, - { name = "evo-objects", extra = ["aiohttp", "utils"] }, + { name = "evo-objects", extra = ["aiohttp", "blockmodels", "utils"] }, { name = "pandas" }, { name = "parameterized" }, { name = "pytest" }, @@ -1030,7 +1035,7 @@ notebooks = [ { name = "jupyter" }, ] test = [ - { name = "evo-objects", extra = ["aiohttp", "utils"] }, + { name = "evo-objects", extra = ["aiohttp", "blockmodels", "utils"] }, { name = "pandas" }, { name = "parameterized" }, { name = "pytest" }, @@ -1038,6 +1043,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "evo-blockmodels", extras = ["pyarrow"], marker = "extra == 'blockmodels'", editable = "packages/evo-blockmodels" }, { name = "evo-sdk-common", extras = ["aiohttp"], marker = "extra == 'aiohttp'", editable = "packages/evo-sdk-common" }, { name = "evo-sdk-common", extras = ["jmespath"], editable = "packages/evo-sdk-common" }, { name = "evo-sdk-common", extras = ["notebooks"], marker = "extra == 'notebooks'", editable = "packages/evo-sdk-common" }, @@ -1047,13 +1053,14 @@ requires-dist = [ { name = "pyarrow-stubs", marker = "extra == 'utils'" }, { name = "pydantic", specifier = ">=2,<3" }, ] -provides-extras = ["aiohttp", "notebooks", "utils"] +provides-extras = ["aiohttp", "notebooks", "utils", "blockmodels"] [package.metadata.requires-dev] dev = [ { name = "bumpver" }, { name = "coverage", extras = ["toml"] }, { name = "evo-objects", extras = ["aiohttp", "utils"], editable = "packages/evo-objects" }, + { name = "evo-objects", extras = ["aiohttp", "utils", "blockmodels"], editable = "packages/evo-objects" }, { name = "pandas" }, { name = "parameterized", specifier = "==0.9.0" }, { name = "pytest" }, @@ -1064,7 +1071,7 @@ notebooks = [ { name = "jupyter" }, ] test = [ - { name = "evo-objects", extras = ["aiohttp", "utils"], editable = "packages/evo-objects" }, + { name = "evo-objects", extras = ["aiohttp", "utils", "blockmodels"], editable = "packages/evo-objects" }, { name = "pandas" }, { name = "parameterized", specifier = "==0.9.0" }, { name = "pytest" }, @@ -1072,7 +1079,7 @@ test = [ [[package]] name = "evo-sdk" -version = "0.1.19" +version = "0.1.20" source = { editable = "." } dependencies = [ { name = "evo-blockmodels", extra = ["aiohttp", "notebooks", "pyarrow"] }, @@ -1137,7 +1144,7 @@ test = [ [[package]] name = "evo-sdk-common" -version = "0.5.17" +version = "0.5.18" source = { editable = "packages/evo-sdk-common" } dependencies = [ { name = "pure-interface" }, @@ -1223,7 +1230,7 @@ test = [ [[package]] name = "evo-widgets" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/evo-widgets" } dependencies = [ { name = "evo-sdk-common" },