Skip to content

Note

Click here to download the full example code

block models

a BlockModel is a grid of boxes, the blocks, with one attribute row per block. three numbers per axis define the grid (origin, block size and block count), plus three angles when it is rotated. the blocks come in three layouts: a regular model holds every cell of the grid, a masked model a subset of them, and a sub-blocked model splits some cells into smaller boxes. you build each one here on grids small enough to count by eye, then read a model back from centroids and from a file.

import sys
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import tempfile

import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, HIGHLIGHT, INK, LIGHT, map_axes, save
from matplotlib.collections import PolyCollection


def draw(ax, model, color=LIGHT, edge="white", **style):
    outlines = model.corners[:, [0, 1, 3, 2], :2]
    ax.add_collection(PolyCollection(outlines, facecolors=color, edgecolors=edge, **style))
    ax.autoscale_view()

a regular grid

the grid has 5 × 4 × 3 blocks of 10 × 10 × 5 m. its origin is the outer corner of the first block (the one with the smallest x, y and z), not that block's center. the first centroid sits half a block from the origin along each axis.

model = bt.BlockModel(origin=(1000, 2000, 300), size=(10, 10, 5), count=(5, 4, 3))
print(model)
print("first corner:  ", model.corners[0, 0])
print("first centroid:", model.coords[0])

Out:

BlockModel(regular, 60 of 60 cells, count [5, 4, 3], size [10.0, 10.0, 5.0], rotation [0.0, 0.0, 0.0])
first corner:   [1000. 2000.  300.]
first centroid: [1005.  2005.   302.5]

cell index, (i, j, k) and centroid

each cell has a linear index that runs along x first, then y, then z: n = i + nx (j + ny k). a regular model stores its rows in that order and stores no coordinates, so row n is cell n. integer division takes an index back to (i, j, k), and the centroid is the origin plus (i + ½, j + ½, k + ½) blocks.

nx, ny, nz = model.count
n = np.arange(len(model))
i, j, k = n % nx, n // nx % ny, n // (nx * ny)
centroids = np.array(model.origin) + (np.column_stack([i, j, k]) + 0.5) * model.size
print("index 27 is (i, j, k) =", (int(i[27]), int(j[27]), int(k[27])), "at", centroids[27])
print("same centroids as the model:", np.allclose(centroids, model.coords))
print("back to the index:", np.array_equal(i + nx * (j + ny * k), n))

Out:

index 27 is (i, j, k) = (2, 1, 1) at [1025.  2015.   307.5]
same centroids as the model: True
back to the index: True

to go from a point to its cell, reverse the last step: subtract the origin, divide by the block size and round down. row_at does this and returns -1 for a point outside the grid.

points = np.array([[1027.0, 2013.0, 308.0], [1049.9, 2039.9, 314.9], [1055.0, 2010.0, 305.0]])
ijk = np.floor((points - model.origin) / model.size).astype(int)
print("(i, j, k):", ijk.tolist())
print("index:    ", (ijk[:, 0] + nx * (ijk[:, 1] + ny * ijk[:, 2])).tolist())
print("row_at:   ", model.row_at(points).tolist())

Out:

(i, j, k): [[2, 1, 1], [4, 3, 2], [5, 1, 1]]
index:     [27, 59, 30]
row_at:    [27, 59, -1]

the third point lies east of the last column, so its i of 5 is out of range and row_at gives -1. the plan shows the middle level (k = 1), each cell labeled with its index and (i, j), and the first point:

level = model.filter(k == 1)
fig, ax = plt.subplots(figsize=(5.2, 4.4), layout="constrained")
draw(ax, level)
for c, index in zip(level.coords, level.index):
    ax.text(
        *c[:2],
        f"{index}\n({index % nx}, {index // nx % ny})",
        ha="center",
        va="center",
        fontsize=7,
        color=INK,
    )
ax.plot(*model.origin[:2], "s", color=ACCENT, ms=5)
ax.annotate("origin", model.origin[:2], xytext=(4, -10), textcoords="offset points", color=ACCENT)
ax.plot(*points[0, :2], "o", color=HIGHLIGHT, ms=5)
map_axes(ax, "Level k = 1: cell index and (i, j)")
save(fig, "indices")

Level k = 1: cell index and (i, j)

a rotated grid

rotation takes azimuth, dip and rake in degrees, and the grid turns about its origin. with an azimuth of 30° the y axis of the grid points N30°E and the x axis N120°E. the rows of corners give those axes: vertices 1, 2 and 4 of a block sit one block length from vertex 0 along x, y and z. written as the columns of a matrix, the axes carry a position in the grid (i + ½, j + ½, k + ½ blocks) to the world, and the transposed matrix carries a world point back into the grid.

rotated = bt.BlockModel(origin=(1000, 2000, 300), size=(10, 10, 5), count=(5, 4, 3), rotation=(30, 0, 0))
corner = rotated.corners[0]
axes = ((corner[[1, 2, 4]] - corner[0]) / np.array(rotated.size)[:, None]).T
print("x axis", axes[:, 0].round(3), " y axis", axes[:, 1].round(3))
local = (np.column_stack([i, j, k]) + 0.5) * rotated.size
print("same centroids:", np.allclose(np.array(rotated.origin) + local @ axes.T, rotated.coords))

point = np.array([1032.0, 2012.0, 308.0])
ijk = np.floor((point - rotated.origin) @ axes / rotated.size).astype(int)
print(
    "point in cell",
    ijk.tolist(),
    "index",
    ijk[0] + nx * (ijk[1] + ny * ijk[2]),
    "row_at",
    rotated.row_at([point]),
)

Out:

x axis [ 0.866 -0.5    0.   ]  y axis [0.5   0.866 0.   ]
same centroids: True
point in cell [2, 2, 1] index 32 row_at [32]

the same level of the rotated grid, with the grid axes drawn from the origin. indices keep their order in the grid frame, so cell 20 stays at the origin corner whatever the rotation:

level = rotated.filter(k == 1)
fig, ax = plt.subplots(figsize=(5.2, 5.2), layout="constrained")
draw(ax, level)
for c, index in zip(level.coords, level.index):
    ax.text(*c[:2], index, ha="center", va="center", fontsize=7, color=INK)
for axis, name in zip(axes.T[:2], "xy"):
    ax.annotate(
        "",
        rotated.origin[:2] + 15 * axis[:2],
        rotated.origin[:2],
        arrowprops={"arrowstyle": "->", "color": ACCENT},
    )
    ax.text(*(rotated.origin[:2] + 18 * axis[:2]), name, color=ACCENT, ha="center", va="center")
ax.plot(*point[:2], "o", color=HIGHLIGHT, ms=5)
map_axes(ax, "Rotated 30°: level k = 1")
save(fig, "rotated")

Rotated 30°: level k = 1

to size a rotated grid on data instead of choosing its origin and count by hand, see block model from extents.

masked layout

a masked model keeps some cells of the grid and drops the rest. it stores the geometry once and the sorted indices of the cells present, so row and cell index part ways: index[row] is the cell. here a 2D grid of 20 × 16 blocks of 10 m keeps the cells whose centroid falls inside a domain outline. a 2D grid has one level (nz = 1), and its blocks take a unit height.

grid = bt.BlockModel(origin=(0, 0), size=(10, 10), count=(20, 16))
outline = bt.Polylines([[[25, 30], [150, 12], [190, 90], [120, 150], [40, 125]]], closed=True)
domain = grid.filter(outline.contains(grid))
print(domain)
print("first rows hold cells", domain.index[:5].tolist())

points = np.array([[62.0, 44.0], [5.0, 5.0]])
row = domain.row_at(points)
print(
    "rows", row.tolist(), "-> cell", domain.index[row[0]], "| in the full grid:", grid.row_at(points).tolist()
)

Out:

BlockModel(masked, 159 of 320 cells, count [20, 16, 1], size [10.0, 10.0, 1.0], rotation [0.0, 0.0, 0.0])
first rows hold cells [33, 34, 46, 47, 48]
rows [28, -1] -> cell 86 | in the full grid: [86, 0]

the second point falls in cell 0, which the mask dropped, so the masked model returns -1 where the full grid returns row 0. to_regular puts every cell back and leaves the dropped ones null.

domain = domain.with_column("cu", np.random.default_rng(7).gamma(4, 0.25, len(domain)))
full = domain.to_regular()
print(full, "| null cells:", np.isnan(full["cu"]).sum())

fig, ax = plt.subplots(figsize=(5.6, 4.6), layout="constrained")
draw(ax, grid)
draw(ax, domain, color=ACCENT)
ring = np.vstack([outline.parts[0], outline.parts[0][:1]])
ax.plot(ring[:, 0], ring[:, 1], color=HIGHLIGHT, lw=1.4)
ax.plot(*points.T, "o", color=HIGHLIGHT, ms=5)
map_axes(ax, f"Masked: {len(domain)} of {len(grid)} cells inside the outline")
save(fig, "masked")

Masked: 159 of 320 cells inside the outline

Out:

BlockModel(regular, 320 of 320 cells, count [20, 16, 1], size [10.0, 10.0, 1.0], rotation [0.0, 0.0, 0.0])
  cu: Float64 | null cells: 161

masking suits models that fill a fraction of their grid, such as one domain in a large box. the models larger than memory page builds one level by level.

sub-blocked layout

a sub-blocked model keeps a parent grid and gives each row a parent cell and an extent inside it, as fractions of the parent from 0 to 1: minimum u, v, w, then maximum u, v, w. a parent can hold one row, whole or partial, or several smaller boxes. with subgrid=(4, 4, 1) each fraction must fall on quarters of a parent. here cell 0 stays whole, cell 1 splits into four quarters and cell 4 keeps only its southern quarter.

parents = np.array([0, 1, 1, 1, 1, 4], dtype=np.uint64)
extents = [
    [0, 0, 0, 1, 1, 1],
    [0, 0, 0, 0.5, 0.5, 1],
    [0.5, 0, 0, 1, 0.5, 1],
    [0, 0.5, 0, 0.5, 1, 1],
    [0.5, 0.5, 0, 1, 1, 1],
    [0, 0, 0, 1, 0.25, 1],
]
cu = [0.4, 1.2, 0.9, 0.3, 0.5, 2.0]
blocks = bt.BlockModel.subblocked(
    (0, 0), (10, 10), (3, 2), parents, extents, subgrid=(4, 4, 1), attributes={"cu": cu}
)
print(blocks)
print("areas:", blocks.volumes.tolist())
merged = blocks.to_regular()
print("per parent:", merged["cu"].round(2).tolist())

fig, ax = plt.subplots(figsize=(4.6, 3.4), layout="constrained")
draw(ax, blocks, color=ACCENT)
draw(ax, bt.BlockModel(origin=(0, 0), size=(10, 10), count=(3, 2)), color="none", edge=GRAY, lw=1.2)
for c, parent, value in zip(blocks.coords, blocks.index, cu):
    ax.text(*c[:2], f"{parent}: {value}", ha="center", va="center", fontsize=7, color="white")
map_axes(ax, "Sub-blocks in a 3 × 2 parent grid")
save(fig, "subblocks")

Sub-blocks in a 3 × 2 parent grid

Out:

BlockModel(sub-blocked, 6 sub-blocks in 6 cells, count [3, 2, 1], size [10.0, 10.0, 1.0], rotation [0.0, 0.0, 0.0])
  cu: Float64
areas: [100.0, 25.0, 25.0, 25.0, 25.0, 25.0]
per parent: [0.4, 0.72, nan, nan, 2.0, nan]

to_regular merged each parent back into one row. floats become area-weighted means (1.2, 0.9, 0.3 and 0.5 average to 0.72 in cell 1), and cell 4 keeps the 2.0 of its only sub-block. cells 2, 3 and 5 hold no sub-block and stay null. in practice sub-blocks come from solids and surfaces; the sub-blocks page builds them from meshes and regularizes them.

a model from centroids

block models often arrive as a table of centroids and attributes, one row per block, with the block size known from elsewhere. the outer centroids lie half a block inside the grid, so from_extents with a buffer of half a block recovers it, and row_at gives each centroid its cell. the table here is the masked domain from above, written as CSV.

folder = Path(tempfile.mkdtemp())
bt.write_csv(folder / "domain.csv", domain)
table = bt.read_csv(folder / "domain.csv")
xyz = np.column_stack([table["x"], table["y"], table["z"]])
size = (10.0, 10.0, 1.0)
box = bt.BlockModel.from_extents(xyz, size=size, buffer=np.array(size) / 2)
cells = box.row_at(xyz).astype(np.uint64)
order = np.argsort(cells)
rebuilt = bt.BlockModel(
    box.origin, size, box.count, index=cells[order], attributes={"cu": table["cu"][order]}
)
print(rebuilt)
print("origin", rebuilt.origin, "count", rebuilt.count)

Out:

BlockModel(masked, 159 of 224 cells, count [16, 14, 1], size [10.0, 10.0, 1.0], rotation [0.0, 0.0, 0.0])
  cu: Float64
origin [30.0, 10.0, 0.0] count [16, 14, 1]

the domain spans fewer cells than the grid it came from, so the rebuilt grid is smaller, starts elsewhere (origin 30, 10 instead of 0, 0) and numbers its cells differently. the blocks coincide: same centroids, same values.

print("same centroids:", np.allclose(rebuilt.coords, domain.coords))
print("same values:   ", np.allclose(rebuilt["cu"], domain["cu"]))

Out:

same centroids: True
same values:    True

without a known block size, take the smallest gap between distinct centroid coordinates along each axis, as long as at least two neighboring blocks share a row.

round trip to a file

CSV kept only centroids and attributes. parquet keeps the whole model: geometry, rotation, layout, index and CRS travel in the file metadata. the rotated grid, masked to its middle level, goes out and comes back unchanged.

middle = rotated.with_column("cu", np.linspace(0.1, 3.0, len(rotated))).filter(k == 1)
bt.write_parquet(folder / "middle.parquet", middle)
back = bt.read_parquet(folder / "middle.parquet")
print(back)
print("origin", back.origin, "rotation", back.rotation)
print(
    "same cells:",
    np.array_equal(back.index, middle.index),
    "| same cu:",
    np.array_equal(back["cu"], middle["cu"]),
)

Out:

BlockModel(masked, 20 of 60 cells, count [5, 4, 3], size [10.0, 10.0, 5.0], rotation [30.0, 0.0, 0.0])
  cu: Float64
origin [1000.0, 2000.0, 300.0] rotation [30.0, 0.0, 0.0]
same cells: True | same cu: True

storing containers in parquet covers the file format, reading a model with polars, and models of other layouts.

Total running time of the script: ( 0 minutes 0.385 seconds)

Download Python source code: example_01_02.py

Download Jupyter notebook: example_01_02.ipynb

Gallery generated by mkdocs-gallery