Source code for parq_blockmodel.reblocking.reblocking

"""
reblocking.py

Module for reblocking operations on block models.
"""
from typing import TYPE_CHECKING
import json

import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

from parq_blockmodel.reblocking.conversion import tabular_to_3d_dict, dict_3d_to_tabular
from parq_blockmodel.reblocking.downsample import downsample_attributes
from parq_blockmodel.reblocking.upsample import upsample_attributes
from parq_blockmodel.utils.spatial_encoding import encode_world_coordinates, get_world_id_encoding_params
from parq_blockmodel.io.ingest_utils import build_world_id_encoding_from_xyz

if TYPE_CHECKING:
    from parq_blockmodel import ParquetBlockModel
from parq_blockmodel.geometry import RegularGeometry, LocalGeometry, WorldFrame


def _validate_params(config, new_block_size):
    if not isinstance(new_block_size, tuple) or len(new_block_size) != 3:
        raise ValueError("new_block_size must be a tuple of three floats (dx, dy, dz).")
    if any(dim <= 0 for dim in new_block_size):
        raise ValueError("All dimensions in new_block_size must be positive.")
    if not isinstance(config, dict):
        raise ValueError("config must be a dictionary.")

def _persisted_attribute_columns(blockmodel) -> list[str]:
    geometry_cols = {"block_id", "world_id", "x", "y", "z", "i", "j", "k"}
    return [c for c in blockmodel.columns if c not in geometry_cols]


def _required_downsample_inputs(aggregation_config: dict[str, dict]) -> set[str]:
    required: set[str] = set(aggregation_config)
    for config in aggregation_config.values():
        if not isinstance(config, dict):
            continue
        basis_name = config.get("basis", config.get("weight"))
        if isinstance(basis_name, str) and basis_name:
            required.add(basis_name)
        fill_ratio_name = config.get("fill_ratio")
        if isinstance(fill_ratio_name, str) and fill_ratio_name:
            required.add(fill_ratio_name)
    return required


def _prepare_arrays_and_index(
    blockmodel,
    requested_columns: set[str] | None = None,
) -> tuple[dict[str, np.ndarray], dict[str, pd.Index]]:
    """Prepare dense attribute arrays and category indices for reblocking.

    This helper reads the blockmodel on a dense ijk grid and converts it
    into 3D arrays suitable for up/downsampling. Geometry-derived columns
    (coordinates and linear indices) are excluded and later recomputed from
    the new :class:`RegularGeometry` to avoid inconsistencies.
    """

    # Read a dense ijk-indexed view of the data. ``ParquetBlockModel.read``
    # already returns a C-order (i, j, k) MultiIndex when ``index="ijk"``,
    # which is exactly what ``tabular_to_3d_dict`` expects.
    #
    # Only load true attributes; drop geometry/identity columns which
    # will be regenerated for the reblocked grid.
    cols = _persisted_attribute_columns(blockmodel)
    if requested_columns is not None:
        cols = [c for c in cols if c in requested_columns]
        extras = sorted(c for c in requested_columns if c not in cols)
        cols.extend(extras)
    df: pd.DataFrame = blockmodel.read(columns=cols, index="ijk", dense=True)
    arrays, categories = tabular_to_3d_dict(df)
    return arrays, categories

def _calculate_factors(blockmodel, new_block_size):
    old_shape = blockmodel.geometry.local.shape
    old_block_size = blockmodel.geometry.local.block_size

    factors = []
    for ob, nb in zip(old_block_size, new_block_size):
        factor = nb / ob
        if np.isclose(factor, round(factor)):
            factors.append(round(factor))
        elif np.isclose(1 / factor, round(1 / factor)):
            factors.append(1 / round(1 / factor))
        else:
            raise ValueError("Block sizes must be integer multiples or divisors for up/downsampling.")

    fx, fy, fz = factors
    if all(f >= 1 for f in factors):
        # Downsampling
        new_shape = tuple(int(o // f) for o, f in zip(old_shape, factors))
    elif all(f <= 1 for f in factors):
        # Upsampling
        up_factors = [int(round(1 / f)) for f in factors]
        new_shape = tuple(int(o * uf) for o, uf in zip(old_shape, up_factors))
    else:
        raise ValueError("Cannot mix upsampling and downsampling in one operation.")

    return fx, fy, fz, new_shape

def _build_new_geometry(blockmodel, new_block_size, new_shape) -> RegularGeometry:
    return RegularGeometry(
        local=LocalGeometry(
            corner=blockmodel.geometry.local.corner,
            block_size=new_block_size,
            shape=new_shape,
        ),
        world=WorldFrame(
            origin=blockmodel.geometry.world.origin,
            axis_u=blockmodel.geometry.world.axis_u,
            axis_v=blockmodel.geometry.world.axis_v,
            axis_w=blockmodel.geometry.world.axis_w,
            srs=blockmodel.geometry.world.srs,
        ),
        world_id_encoding=blockmodel.geometry.world_id_encoding,
    )


[docs] def downsample_blockmodel(blockmodel, new_block_size, aggregation_config) -> "ParquetBlockModel": """ Downsample a block model to a coarser grid with specified aggregation methods for each attribute. This function supports downsampling of both categorical and numeric attributes. Args: blockmodel: ParquetBlockModel instance. new_block_size: tuple of floats (dx, dy, dz) for the new block size. aggregation_config: dict mapping attribute names to aggregation methods. Use ``basis`` for ``weighted_mean`` configurations. Example: aggregation_config = { 'grade': {'method': 'weighted_mean', 'basis': 'dry_mass'}, 'density': {'method': 'weighted_mean', 'basis': 'volume'}, 'dry_mass': {'method': 'sum'}, 'volume': {'method': 'sum'}, 'rock_type': {'method': 'mode'} } Returns: ParquetBlockModel: A new ParquetBlockModel instance with the downsampled grid. """ from parq_blockmodel import ParquetBlockModel _validate_params(aggregation_config, new_block_size) fx, fy, fz, new_shape = _calculate_factors(blockmodel, new_block_size) if fx > 1 or fy > 1 or fz > 1: # Downsampling: expect dict of dicts if not all(isinstance(v, dict) for v in (aggregation_config or {}).values()): raise ValueError("Downsampling config must be a dict of dicts per attribute.") required_inputs = _required_downsample_inputs(aggregation_config or {}) arrays, categories = _prepare_arrays_and_index(blockmodel, requested_columns=required_inputs) arrays = downsample_attributes(arrays, fx, fy, fz, aggregation_config or {}) transient_inputs = required_inputs - set(_persisted_attribute_columns(blockmodel)) - set(aggregation_config or {}) for column in transient_inputs: arrays.pop(column, None) elif fx < 1 or fy < 1 or fz < 1: raise ValueError("reblocking factors imply upsampling, not downsampling.") elif any(dim < 1 for dim in (fx, fy, fz)) and any(dim > 1 for dim in (fx, fy, fz)): raise ValueError("Reblocking factors cannot specify upsample and downsample at the same time.") # Convert back to DataFrame (ijk-indexed attributes only) new_geometry = _build_new_geometry(blockmodel, new_block_size, new_shape) # Use canonical .pbm suffix expected by ParquetBlockModel new_path = blockmodel.blockmodel_path.with_name(f"{blockmodel.name}.reblocked.pbm") reblocked_df = dict_3d_to_tabular(arrays, categories) # assert the indexes are aligned in ijk space if not reblocked_df.index.equals(new_geometry.to_multi_index_ijk()): raise ValueError("Reblocking ijk index mismatch.") # ------------------------------------------------------------------ # Reconstruct geometry-derived columns for the new grid # ------------------------------------------------------------------ # x, y, z centroids in C-order matching the dense ijk index reblocked_df["x"] = new_geometry.centroid_x reblocked_df["y"] = new_geometry.centroid_y reblocked_df["z"] = new_geometry.centroid_z # Canonical linear id in C-order of local (i, j, k). N = int(np.prod(new_geometry.local.shape)) rows = np.arange(N, dtype=np.uint32) reblocked_df["block_id"] = rows i, j, k = new_geometry.ijk_from_row_index(rows) reblocked_df["i"] = i.astype(np.int32) reblocked_df["j"] = j.astype(np.int32) reblocked_df["k"] = k.astype(np.int32) if new_geometry.world_id_encoding is None: new_geometry.world_id_encoding = build_world_id_encoding_from_xyz( reblocked_df["x"].to_numpy(dtype=float), reblocked_df["y"].to_numpy(dtype=float), reblocked_df["z"].to_numpy(dtype=float), ) offset, scale, bits_per_axis = get_world_id_encoding_params(new_geometry.world_id_encoding) reblocked_df["world_id"] = encode_world_coordinates( reblocked_df["x"].to_numpy(dtype=float), reblocked_df["y"].to_numpy(dtype=float), reblocked_df["z"].to_numpy(dtype=float), offset=offset, scale=scale, bits_per_axis=bits_per_axis, ).astype(np.int64) ordered_cols = ["block_id", "world_id", "x", "y", "z", "i", "j", "k"] ordered_cols.extend([c for c in reblocked_df.columns if c not in ordered_cols]) table = pa.Table.from_pandas(reblocked_df[ordered_cols].reset_index(drop=True), preserve_index=False) meta = dict(table.schema.metadata or {}) meta[b"parq-blockmodel"] = json.dumps(new_geometry.to_metadata_dict()).encode("utf-8") table = table.replace_schema_metadata(meta) pq.write_table(table, new_path) return ParquetBlockModel(blockmodel_path=new_path, geometry=new_geometry)
[docs] def upsample_blockmodel(blockmodel, new_block_size, upsample_config=None, interpolation_config=None) -> "ParquetBlockModel": """ Upsample a block model to a finer grid with explicit per-attribute methods. This function supports both continuous interpolation and class-preserving assignment methods. Args: blockmodel: ParquetBlockModel instance. new_block_size: tuple of floats (dx, dy, dz) for the new block size. upsample_config: dict mapping each attribute name to one method ("linear", "nearest", "mode", or "parent"). interpolation_config: deprecated alias of ``upsample_config``. Example: upsample_config = { 'grade': 'linear', 'density': 'linear', 'dry_mass': 'linear', 'volume': 'linear', 'rock_type': 'mode' } Returns: ParquetBlockModel: A new ParquetBlockModel instance with the upsampled grid. """ from parq_blockmodel import ParquetBlockModel if upsample_config is not None and interpolation_config is not None: raise ValueError("Provide only one of 'upsample_config' or deprecated 'interpolation_config'.") effective_config = upsample_config if upsample_config is not None else interpolation_config _validate_params(effective_config, new_block_size) arrays, categories = _prepare_arrays_and_index(blockmodel) fx, fy, fz, new_shape = _calculate_factors(blockmodel, new_block_size) if fx > 1 or fy > 1 or fz > 1: raise ValueError("reblocking factors imply downsampling, not upsampling.") elif fx < 1 or fy < 1 or fz < 1: # Upsampling: expect dict of strings if not all(isinstance(v, str) for v in (effective_config or {}).values()): raise ValueError("Upsampling config must be a dict of interpolation methods per attribute.") missing = sorted(set(arrays) - set((effective_config or {}))) if missing: raise ValueError( "Upsampling config must specify a method for every attribute. " f"Missing: {missing}" ) arrays = upsample_attributes(arrays, int(1 // fx), int(1 // fy), int(1 // fz), effective_config or {}) elif any(dim < 1 for dim in (fx, fy, fz)) and any(dim > 1 for dim in (fx, fy, fz)): raise ValueError("Reblocking factors cannot specify upsample and downsample at the same time.") # Convert back to DataFrame (ijk-indexed attributes only) new_geometry = _build_new_geometry(blockmodel, new_block_size, new_shape) # Use canonical .pbm suffix expected by ParquetBlockModel new_path = blockmodel.blockmodel_path.with_name(f"{blockmodel.name}.reblocked.pbm") reblocked_df = dict_3d_to_tabular(arrays, categories) # assert the indexes are aligned in ijk space if not reblocked_df.index.equals(new_geometry.to_multi_index_ijk()): raise ValueError("Upsampling ijk index mismatch.") # ------------------------------------------------------------------ # Reconstruct geometry-derived columns for the new grid # ------------------------------------------------------------------ reblocked_df["x"] = new_geometry.centroid_x reblocked_df["y"] = new_geometry.centroid_y reblocked_df["z"] = new_geometry.centroid_z N = int(np.prod(new_geometry.local.shape)) rows = np.arange(N, dtype=np.uint32) reblocked_df["block_id"] = rows i, j, k = new_geometry.ijk_from_row_index(rows) reblocked_df["i"] = i.astype(np.int32) reblocked_df["j"] = j.astype(np.int32) reblocked_df["k"] = k.astype(np.int32) if new_geometry.world_id_encoding is None: new_geometry.world_id_encoding = build_world_id_encoding_from_xyz( reblocked_df["x"].to_numpy(dtype=float), reblocked_df["y"].to_numpy(dtype=float), reblocked_df["z"].to_numpy(dtype=float), ) offset, scale, bits_per_axis = get_world_id_encoding_params(new_geometry.world_id_encoding) reblocked_df["world_id"] = encode_world_coordinates( reblocked_df["x"].to_numpy(dtype=float), reblocked_df["y"].to_numpy(dtype=float), reblocked_df["z"].to_numpy(dtype=float), offset=offset, scale=scale, bits_per_axis=bits_per_axis, ).astype(np.int64) ordered_cols = ["block_id", "world_id", "x", "y", "z", "i", "j", "k"] ordered_cols.extend([c for c in reblocked_df.columns if c not in ordered_cols]) table = pa.Table.from_pandas(reblocked_df[ordered_cols].reset_index(drop=True), preserve_index=False) meta = dict(table.schema.metadata or {}) meta[b"parq-blockmodel"] = json.dumps(new_geometry.to_metadata_dict()).encode("utf-8") table = table.replace_schema_metadata(meta) pq.write_table(table, new_path) return ParquetBlockModel(blockmodel_path=new_path, geometry=new_geometry)