Sampling in Voronoi grids

In RT computations, the need to uniformly sample points in the cells of a grid often arises. A typical example is that of the evaluation of the average value of a function (e.g., a density function) within a cell.

Whereas this task is relatively simple in regular grids, the situation is more complicated in Voronoi grids. Cells in Voronoi grids are indeed convex polyhedra with an arbitrary number of faces, whose shapes and volumes are determined by the intial distribution of sites.

The Voronoi helper class in Hyperion contains support for producing a list of sampling points for each cell.

Simple case: averaging of function

In the simplest case, we might want to evaluate a function in all cells (for example a function to find the density), and rather than using only the position of the sites, we want to use several random samples in each cell.

The easiest way to do this is to make use of the top-level VoronoiGrid class and the evaluate_function_average() method. Suppose we have a density function defined as

def density_func(x,y,z):
   # Density is proportional to the inverse
   # square of the distance from the origin.
   density = 1 / (x*x + y*y + z*z)
   return density

We can now generate some random sites:

>>> import numpy as np
>>> N = 100000
>>> x = np.random.uniform(size=N)
>>> y = np.random.uniform(size=N)
>>> z = np.random.uniform(size=N)

and set up a voronoi grid:

>>> from hyperion.grid import VoronoiGrid
>>> g = VoronoiGrid(x, y, z)

We can then simply call the evaluate_function_average() method to evaluate the function at n_samples sites in total, with a minimum of min_cell_samples samples in each cell:

>>> dens = g.evaluate_function_average(density_func,
                                       n_samples=1000000,
                                       min_cell_samples=5)

Advanced: accessing the random samples directly

In this example, we now show how to manually get the random samples in each cell and show to how to do the same function evaluation as above.

As before, we first initialise a cubic domain with unitary edge, and we fill it with randomly-placed points:

>>> import numpy as np
>>> N = 100000
>>> x = np.random.uniform(size=N)
>>> y = np.random.uniform(size=N)
>>> z = np.random.uniform(size=N)
>>> sites_arr = np.array([x, y, z]).transpose()

Next, we import the voronoi_helpers module and we compute the Voronoi grid g generated by the points in sites_arr:

>>> from hyperion.grid import voronoi_helpers as vh
>>> g = vh.voronoi_grid(sites_arr, np.array([[0, 1.], [0, 1], [0, 1]]),n_samples=1000000,min_cell_samples=10)
INFO: Computing the tessellation via voro++ [hyperion.grid.voronoi_helpers]

Here we have passed to voronoi_grid two extra parameters related to the sampling:

  • n_samples is the (approximate) total number of sampling points to be produced. For each cell, the algorithm will produce a number of sampling points that is proportional to the cell volume: larger cells will be sampled with more points than smaller cells;
  • min_cell_samples is the minimum number of sampling points per cell. If a cell is small enough, it could be that no sampling points are allotted to it. This parameters forces the minimum number of sampling points to be allocated to a cell, regardless of its volume. The default value for this parameter, if omitted, is 10.

When a Voronoi grid is constructed with a positive n_samples parameters, it will expose two properties:

  • samples is a list of three-dimensional vectors representing all sampling points in the domain;
  • samples_idx is a list of indices describing, in sparse format, to which cell the points in samples belong.

For instance, in the example above:

>>> g.samples
array([[ 0.57565603,  0.9219989 ,  0.15469812],
       [ 0.58406352,  0.91473664,  0.15834503],
       [ 0.57642814,  0.93045367,  0.16361907],
       ...,
       [ 0.80025712,  0.18526818,  0.61809793],
       [ 0.78721772,  0.18366617,  0.62582103],
       [ 0.79493898,  0.17735752,  0.62803905]])
>>> g.samples_idx
array([      0,      10,      20, ..., 1147105, 1147115, 1147131], dtype=int32)

This means that the sampling points for the first cell have indices 0 to 10 in g.samples, the sampling points for the second cell have indices 10 to 20 in g.samples, and so on.

If now we suppose to have a density function defined as

def density_func(x,y,z):
   # Density is proportional to the inverse
   # square of the distance from the origin.
   density = 1 / (x*x + y*y + z*z)
   return density

where x, y and z are 1-D numpy arrays of coordinates, we can then first compute the density at all sampling points like this:

>>> dens_all = density_func(g.samples[:,0],g.samples[:,1],g.samples[:,2])

We can then compute the average density per cell with:

>>> dens_average = np.add.reduceat(dens_all, g.samples_idx[:-1]) / np.diff(g.samples_idx)

That is, dens_average will be an array of 1E5 elements each containing the average value of density_func() for each cell of the grid:

>>> dens_average
array([ 0.8288213 ,  3.24626334,  0.74344873, ...,  2.98673651,
        0.64962755,  0.96117706])
>>> len(dens_average)
100000