Source code for parq_blockmodel.utils.demo_block_model

from pathlib import Path
from typing import Literal, Tuple

import numpy as np
import pandas as pd

from parq_blockmodel.utils.geometry_utils import rotate_points


[docs] def create_demo_blockmodel(shape: tuple[int, int, int] = (3, 3, 3), block_size: tuple[float, float, float] = (1.0, 1.0, 1.0), corner: tuple[float, float, float] = (0.0, 0.0, 0.0), azimuth: float = 0.0, dip: float = 0.0, plunge: float = 0.0, parquet_filepath: Path = None, index_type: Literal["block_index", "world_centroids", "block_id"] = "block_index", ) -> pd.DataFrame | Path: """ Create a synthetic block model DataFrame or Parquet file. This function generates a rectilinear block model defined in logical index space (i, j, k), together with world–space centroids and optional rotation. The function is intended for testing, exploration, and examples. ───────────────────────────────────────────────────────────────────────────── BLOCK MODEL INDEXING ───────────────────────────────────────────────────────────────────────────── Logical indices: - (i, j, k) represent block indices along the x, y, and z axes. - Sorting by (i, j, k) gives the canonical logical block‑model order: i varies slowest j varies next k varies fastest This ordering is *purely logical* and independent of how NumPy stores the data in memory. Canonical block id: - block_id: Canonical linear id obtained by flattening logical (i,j,k) coordinates in NumPy C-order. This is frame-invariant and remains stable regardless of world-space rotation. ───────────────────────────────────────────────────────────────────────────── WORLD COORDINATES ───────────────────────────────────────────────────────────────────────────── World centroids are computed using: x = corner_x + (i + 0.5) * dx y = corner_y + (j + 0.5) * dy z = corner_z + (k + 0.5) * dz If azimuth, dip, or plunge are non‑zero, the world positions are rotated about the origin after centroid generation. ───────────────────────────────────────────────────────────────────────────── DEPTH INFORMATION ───────────────────────────────────────────────────────────────────────────── - depth: Computed as (maximum z centroid + dz/2) − z. - depth_category: Categorical split into 'shallow' and 'deep'. ───────────────────────────────────────────────────────────────────────────── DATAFRAME INDEXING OPTION ───────────────────────────────────────────────────────────────────────────── index_type: - "block_index": the DataFrame is indexed by (i, j, k). - "world_centroids": indexed by (x, y, z). - "block_id": indexed by canonical block_id. ───────────────────────────────────────────────────────────────────────────── ATTRIBUTES ───────────────────────────────────────────────────────────────────────────── The DataFrame is assigned lightweight geometry metadata under: df.attrs["geometry"] = { "shape": (nx, ny, nz), "block_size": (dx, dy, dz), "corner": (corner_x, corner_y, corner_z), "rotation": { "azimuth": ..., "dip": ..., "plunge": ... }, "geometry_type": "rectilinear_blockmodel", "canonical_identity": "block_id", "index_type": index_type } These attrs are intentionally simpler than the full "parq‑blockmodel" production schema, but pseudo‑compatible and useful for testing. ───────────────────────────────────────────────────────────────────────────── PARAMETERS ───────────────────────────────────────────────────────────────────────────── shape : tuple[int, int, int] Logical block model size (nx, ny, nz). block_size : tuple[float, float, float] Block dimensions (dx, dy, dz). corner : tuple[float, float, float] World‑space minimum corner of the unrotated model. azimuth, dip, plunge : float Rotation angles applied to world centroids. parquet_filepath : Path | None If supplied, the DataFrame is written to Parquet. index_type : {"block_index", "world_centroids", "block_id"} Specifies which index is assigned to the returned DataFrame. ───────────────────────────────────────────────────────────────────────────── RETURNS ───────────────────────────────────────────────────────────────────────────── DataFrame or Path The block model DataFrame, or the Parquet path if saved. """ # ------------------------------------------------------------------ # Canonical logical indices (i, j, k) in C-order # ------------------------------------------------------------------ ni, nj, nk = shape num_blocks = int(np.prod(shape)) # Use NumPy's C-order unravel to generate (i, j, k) rows = np.arange(num_blocks, dtype=int) i, j, k = np.unravel_index(rows, shape, order="C") # ------------------------------------------------------------------ # World-space centroids (x, y, z), optionally rotated # ------------------------------------------------------------------ dx, dy, dz = block_size cx, cy, cz = corner x = cx + (i + 0.5) * dx y = cy + (j + 0.5) * dy z = cz + (k + 0.5) * dz coords = np.column_stack([x, y, z]) if any(angle != 0.0 for angle in (azimuth, dip, plunge)): rotated = rotate_points(points=coords, azimuth=azimuth, dip=dip, plunge=plunge) x, y, z = rotated[:, 0], rotated[:, 1], rotated[:, 2] # Canonical block id from logical indices in C-order. block_id = rows # ------------------------------------------------------------------ # Build DataFrame with canonical logical + world coordinates # ------------------------------------------------------------------ df = pd.DataFrame({ "block_id": block_id, "i": i, "j": j, "k": k, "x": x, "y": y, "z": z, }) # Depth information (using world Z) surface_rl = float(np.max(z) + dz / 2.0) df["depth"] = surface_rl - z df["depth_category"] = pd.cut( df["depth"], bins=2, labels=["shallow", "deep"], include_lowest=True, ).astype("category") # ------------------------------------------------------------------ # Assign index according to index_type # ------------------------------------------------------------------ if index_type == "block_index": df.set_index(["i", "j", "k"], inplace=True) elif index_type == "world_centroids": df.set_index(["x", "y", "z"], inplace=True) elif index_type == "block_id": df.set_index("block_id", inplace=True) else: raise ValueError(f"Unknown index_type: {index_type}") # Lightweight geometry metadata df.attrs.setdefault("geometry", {}) df.attrs["geometry"].update({ "shape": tuple(shape), "block_size": tuple(block_size), "corner": tuple(corner), "rotation": {"azimuth": azimuth, "dip": dip, "plunge": plunge}, "geometry_type": "rectilinear_blockmodel", "canonical_identity": "block_id", "index_type": index_type, }) if parquet_filepath is not None: df.to_parquet(parquet_filepath) return parquet_filepath return df
[docs] def add_gradient_ellipsoid_grade( df: pd.DataFrame, center: Tuple[float, float, float], radii: Tuple[float, float, float], grade_min: float = 5.0, grade_max: float = 65.0, bearing: float = 0.0, dip: float = 0.0, plunge: float = 0, column_name: str = 'grade', noise_std: float = 0.1, noise_seed: int | None = None, ) -> pd.DataFrame: """Add a gradient ellipsoid grade to the block model DataFrame.""" if df.index.names == ['x', 'y', 'z']: coords = np.array(df.index.tolist()) - np.array(center) else: coords = df[['x', 'y', 'z']].values - np.array(center) if any([bearing, dip, plunge]): from parq_blockmodel.utils.geometry_utils import rotate_points coords = rotate_points(coords, bearing, dip, plunge) norm = ( (coords[:, 0] / radii[0]) ** 2 + (coords[:, 1] / radii[1]) ** 2 + (coords[:, 2] / radii[2]) ** 2 ) inside = norm <= 1 grad = np.full_like(norm, grade_min, dtype=float) grad[inside] = grade_max - (grade_max - grade_min) * np.sqrt(norm[inside]) if noise_std > 0.0: rng = np.random.default_rng(noise_seed) grad += rng.normal(0, noise_std, size=grad.shape) df[column_name] = grad return df
[docs] def create_toy_blockmodel( shape=(20, 15, 10), block_size=(1.0, 1.0, 1.0), corner=(0.0, 0.0, 0.0), axis_azimuth=0.0, axis_dip=0.0, axis_plunge=0.0, deposit_bearing=20.0, deposit_dip=30.0, deposit_plunge=10.0, grade_name='grade', grade_min=50.0, grade_max=65.0, deposit_center=(10.0, 7.5, 5.0), deposit_radii=(8.0, 5.0, 3.0), noise_std: float = 0.0, noise_rel: float | None = None, noise_seed: int | None = None, parquet_filepath: Path = None ) -> pd.DataFrame | Path: """Create a toy blockmodel with a gradient ellipsoid grade. Args: shape: Shape of the block model (nx, ny, nz). block_size: Size of each block (dx, dy, dz). corner: The lower left (minimum) corner of the block model. axis_azimuth: The azimuth angle of the block model axis in degrees. axis_dip: The dip angle of the block model axis in degrees. axis_plunge: The plunge angle of the block model axis in degrees. deposit_bearing: The azimuth angle of the deposit in degrees. deposit_dip: The dip angle of the deposit in degrees. deposit_plunge: The plunge angle of the deposit in degrees. grade_name: The name of the column to store the grade values. grade_min: The minimum grade value. grade_max: The maximum grade value. deposit_center: The center of the deposit (x, y, z). deposit_radii: The radii of the deposit (rx, ry, rz). noise_std: Absolute standard deviation of Gaussian noise added to grades. noise_rel: Relative standard deviation expressed as a fraction of ``(grade_max - grade_min)``. Mutually exclusive with ``noise_std``. noise_seed: Optional random seed for reproducible Gaussian noise. parquet_filepath: The file path to save the DataFrame as a Parquet file. If None, returns a DataFrame. Returns: pd.DataFrame if parquet_filepath is None, else Path to the Parquet file. """ if noise_rel is not None: if noise_rel < 0.0: raise ValueError("noise_rel must be >= 0.0") if noise_std not in (0.0, 0): raise ValueError("Specify only one of noise_std or noise_rel") noise_std = float(noise_rel) * float(grade_max - grade_min) df = create_demo_blockmodel(shape, block_size, corner, axis_azimuth, axis_dip, axis_plunge) if not isinstance(df, pd.DataFrame): raise TypeError("create_demo_blockmodel returned a path unexpectedly") df = add_gradient_ellipsoid_grade(df=df, center=deposit_center, radii=deposit_radii, grade_min=grade_min, grade_max=grade_max, bearing=deposit_bearing, dip=deposit_dip, plunge=deposit_plunge, column_name=grade_name, noise_std=noise_std, noise_seed=noise_seed) if parquet_filepath is not None: df.to_parquet(parquet_filepath) return parquet_filepath return df