How to Use H3 Neighbours for k-Ring Smoothing and Buffers

Problem statement

Counts per hexagon at a fine resolution are mostly noise. Binning the 43,712 GeoNames populated places in the United Kingdom into res-7 cells (about 5 kmยฒ each) left 54.1% of the 53,100 land cells empty, and the correlation between a cell and its neighbours was only 0.406 โ€” a map of that surface is a scatter of speckle. The usual remedies are a coarser resolution, which throws away detail everywhere, or kernel density, which needs a projected CRS and a raster. H3 offers a third: every cell knows its neighbours by index arithmetic, so a moving-window average is a dictionary lookup per neighbour.

Measured on the same cells, a k=1 neighbourhood mean cut the empty share to 16.1%, halved the peak from 31 to 15.14, and kept the total within 0.2%. The same neighbour functions give you an approximate buffer โ€” with an error that has to be handled, because a disk of hexagons is not a circle.

Quick answer

grid_disk(cell, k) returns the cell and every cell within k steps. Average over it:

import h3


def smooth(counts, k=1):
    """Mean of each cell and its k-ring neighbours; missing neighbours count as zero."""
    return {
        cell: sum(counts.get(n, 0) for n in h3.grid_disk(cell, k)) / (1 + 3 * k * (k + 1))
        for cell in counts
    }


counts = {h3.latlng_to_cell(51.5074, -0.1278, 8): 12}
print(smooth(counts, k=1))
{'88195da49bfffff': 1.7142857142857142}

Twelve points spread over the seven cells of the disk give 12 / 7. That version divides by the nominal disk size and only visits cells that already have counts; Example 1 fixes both, handling pentagons and the edges of a study area, and it is the one to use on real data.

Measured speed: 100,000 disks at k=1 in 0.27 s, at k=2 in 0.53 s, at k=5 in 1.93 s.

A dark centre hexagon surrounded by a ring of six teal hexagons and an outer ring of twelve light blue hexagons.
The disk is index arithmetic: no geometry is built to find these nineteen cells.

Step-by-step solution

1. Get a disk or a ring

origin = h3.latlng_to_cell(51.5074, -0.1278, 8)       # Charing Cross
for k in range(4):
    print(k, len(h3.grid_disk(origin, k)), len(h3.grid_ring(origin, k)))
0 1 1
1 7 6
2 19 12
3 37 18

grid_disk is filled โ€” everything within k steps, 1 + 3k(k + 1) cells. grid_ring is hollow โ€” exactly k steps away, 6k cells. Use the disk for windows and buffers, the ring when neighbours at different distances need different weights.

In 2,000 random disks the origin came first and ring 1 came next every time, but the h3-py documentation does not promise an order. Ask grid_ring for distances rather than slicing a disk.

2. Expect pentagons to return fewer cells

pentagon = h3.get_pentagons(8)[0]
print(len(h3.grid_disk(pentagon, 1)), len(h3.grid_disk(pentagon, 2)), len(h3.grid_ring(pentagon, 1)))
6 16 5

A pentagon has five neighbours, so its disks hold 6 and 16 cells rather than 7 and 19. Dividing by 1 + 3k(k + 1), as the quick answer does, under-weights those cells. There are only twelve pentagons per resolution and all twelve centres lie at sea, but a global pipeline will meet them.

3. Measure grid distance, and expect it to fail

london = h3.latlng_to_cell(51.5, -0.1, 5)
paris = h3.latlng_to_cell(48.86, 2.35, 5)
new_york = h3.latlng_to_cell(40.7, -74.0, 5)
print(h3.grid_distance(london, paris))
try:
    h3.grid_distance(london, new_york)
except h3.H3FailedError as exc:
    print(type(exc).__name__)
21
H3FailedError

grid_distance counts steps between two cells at the same resolution. It is computed by unfolding the icosahedron faces into flat local IJ coordinates, which cannot be done consistently across a pentagon, where five faces meet, or over long distances โ€” so rather than return a wrong number it raises for cells far apart. It also fails close to a pentagon: in one measured neighbourhood, 360 of 1,600 cell pairs raised.

4. Build the whole study area, zeros included

A dictionary of occupied cells cannot be smoothed into the gaps โ€” the empty cells are not in it. Fill the region first:

import duckdb
import geopandas as gpd

con = duckdb.connect()
con.execute("set threads = 4")
con.execute("install h3 from community; load h3")
occupied = con.execute("""
    select h3_h3_to_string(h3_latlng_to_cell(lat, lon, 7)) as cell, count(*) as n
    from read_parquet('geonames_all.parquet')
    where country_code = 'GB' and feature_class = 'P'
    group by 1
""").df()

countries = gpd.read_file("zip://ne_10m_admin_0_countries.zip")
uk = countries.loc[countries.ADMIN == "United Kingdom", "geometry"].iloc[0]
study = h3.geo_to_cells(uk, 7)

base = dict.fromkeys(study, 0)
base.update({c: n for c, n in zip(occupied.cell, occupied.n) if c in base})
print(f"{len(study):,} cells in the study area, {sum(v > 0 for v in base.values()):,} occupied")
53,100 cells in the study area, 24,358 occupied

The GeoNames points occupied 25,173 cells in total. The other 815 are cells whose centres fall outside the Natural Earth coastline, which a centre-based polygon fill excludes even though places lie inside them.

5. Smooth with a k-ring mean

With Example 1 applied to base, measured on the 53,100 cells:

raw           zero  54.1%  sd 1.212  max  31.00  sum   42,577  neighbour r 0.406
k=1           zero  16.1%  sd 0.832  max  15.14  sum   42,497  neighbour r 0.892
k=2           zero   6.1%  sd 0.742  max  10.95  sum   42,421  neighbour r 0.961
k=1 weighted  zero  16.1%  sd 0.844  max  16.38  sum   42,517  neighbour r 0.888

All three surfaces took 1.0 s together. The weighted version counts the centre cell twice as heavily as its neighbours, and it is only marginally sharper than the plain k=1 mean.

6. Decide what happens at the edge of the study area

9.2% of the UK cells were missing at least one neighbour โ€” coast, mostly. Averaging over the neighbours that exist (outside="ignore") moved the sum from 42,577 to 42,497; treating outside cells as zero (outside="zero") dropped it to 41,307, because coastal counts leak into the sea.

For counts of things that cannot be offshore, ignore. For a sampled surface that really continues beyond the boundary, fetch the neighbouring region instead of pretending it is empty.

7. Treat a disk as a buffer only with a distance check

A disk is a cheap stand-in for "everything within about this distance", and it is not a circle. At res 8 a k=1 disk reached 969 m in its narrowest direction and 1,402 m in its widest (Example 2). Tested against true great-circle distance for a 1 km radius at res 9, k=3 still missed 28 of 1,987 true neighbours, and k=4 missed none โ€” but 782 of 3,000 candidates were beyond 1 km. Use the disk to find candidates, then refine. Choosing k for a given distance, with a rule verified on 140,000 adversarial pairs, is covered in How to join two point datasets on an H3 index.

Grid comparing raw and smoothed counts on empty-cell share, standard deviation, maximum and neighbour correlation.
The neighbour correlation of a smoothed surface is mostly the smoother, so test autocorrelation on the raw counts.

Code examples

Example 1 โ€” smoothing a cell surface with weights and edge handling

import h3
import numpy as np
import pandas as pd


def kring_smooth(values, k=1, weights=None, outside="ignore"):
    """Smooth a {cell: value} surface over H3 rings.

    weights: one weight per ring distance 0..k (default: all 1).
    outside: 'ignore' averages over neighbours that exist in `values`;
             'zero' treats neighbours outside the study area as zero.
    """
    weights = weights or [1.0] * (k + 1)
    out = {}
    for cell in values:
        num = den = 0.0
        for d in range(k + 1):
            ring = [cell] if d == 0 else h3.grid_ring(cell, d)
            for n in ring:
                if n in values:
                    num += weights[d] * values[n]
                    den += weights[d]
                elif outside == "zero":
                    den += weights[d]
        out[cell] = num / den
    return pd.Series(out)


def neighbour_correlation(series):
    """Correlation between each cell's value and each of its ring-1 neighbours'."""
    a, b = [], []
    for cell, v in series.items():
        for n in h3.grid_ring(cell, 1):
            if n in series.index:
                a.append(v)
                b.append(series[n])
    return np.corrcoef(a, b)[0, 1]

The function divides by the weights that actually took part, so a pentagon's five neighbours and a coastal cell's surviving neighbours are averaged correctly. The output on the UK surface is the table in step 5; neighbour_correlation produced its last column.

Example 2 โ€” how round is a disk?

from pyproj import Transformer
from shapely import union_all
from shapely.geometry import Point, Polygon


def disk_radii(lat, lng, res, k):
    """Inner and outer radius, in metres, of the k-disk around the cell containing a point."""
    cell = h3.latlng_to_cell(lat, lng, res)
    clat, clng = h3.cell_to_latlng(cell)
    to_m = Transformer.from_crs(
        "EPSG:4326", f"+proj=aeqd +lat_0={clat} +lon_0={clng} +units=m", always_xy=True
    )
    hexes = []
    for n in h3.grid_disk(cell, k):
        lngs, lats = zip(*[(b, a) for a, b in h3.cell_to_boundary(n)])
        hexes.append(Polygon(zip(*to_m.transform(lngs, lats))))
    outline = union_all(hexes).exterior
    inner = outline.distance(Point(0, 0))
    outer = max(Point(xy).distance(Point(0, 0)) for xy in outline.coords)
    return inner, outer


for res, k in [(8, 1), (8, 3), (8, 6), (9, 5)]:
    inner, outer = disk_radii(51.5074, -0.1278, res, k)
    print(f"res {res} k={k}: inner {inner:6,.0f} m  outer {outer:6,.0f} m  ratio {outer / inner:.3f}")
res 8 k=1: inner    969 m  outer  1,402 m  ratio 1.447
res 8 k=3: inner  2,421 m  outer  3,214 m  ratio 1.328
res 8 k=6: inner  4,598 m  outer  5,947 m  ratio 1.293
res 9 k=5: inner  1,453 m  outer  1,912 m  ratio 1.316

The inner radius is the guarantee: everything closer than that to the cell's centre is inside the disk. The outer radius is the worst over-reach.

Example 3 โ€” a distance that does not raise

def cell_distance_km(a, b):
    """Grid steps when H3 can compute them, great-circle distance otherwise."""
    try:
        steps = h3.grid_distance(a, b)
    except h3.H3FailedError:
        steps = None
    km = h3.great_circle_distance(h3.cell_to_latlng(a), h3.cell_to_latlng(b), "km")
    return steps, round(km, 1)


print(cell_distance_km(london, paris))
print(cell_distance_km(london, new_york))
(21, 340.8)
(None, 5582.3)

Grid steps are useful for "within k cells" logic; kilometres are what most questions actually ask. Returning both, and never raising, keeps a batch job alive when one pair straddles a pentagon.

Explanation

Why hexagon neighbours make a good smoothing window

Every H3 neighbour shares an edge, and neighbour centres sit at nearly the same distance. Measured at res 7 in London, the six centre distances ranged from 2.242 to 2.425 km โ€” within 8.2%. The eight neighbours of a geohash cell at precision 6 ranged from 0.611 to 0.975 km, a ratio of 1.597, because corner neighbours are further away than edge neighbours.

A square window therefore weights diagonal directions differently from straight ones. A hexagonal ring does not need a correction.

Why smoothing trades location for stability

Averaging seven cells divides the sampling noise, which is why the standard deviation fell from 1.212 to 0.832 at k=1 and the empty share from 54.1% to 16.1%. The price is location: the single busiest raw cell (31 places, in the City of London) was diluted to a mean of its neighbourhood, and the k=1 maximum moved to a cell about 2 km west.

At k=2 each value averages nineteen res-7 cells, about 98 kmยฒ or nearly three res-6 cells. The advantage over simply choosing a coarser resolution is that the window slides: every small hexagon gets its own neighbourhood, so the map keeps its fine grain.

Why the neighbour correlation is not a finding

Smoothing makes neighbours share inputs. Two adjacent k=1 cells average overlapping windows, so their values are correlated by construction: 0.406 raw, 0.892 at k=1, 0.961 at k=2.

That correlation belongs to the smoother, not the data. Any statistic about spatial dependence โ€” Moran's I, a hot-spot test, a regression residual check โ€” must run on the raw counts, with the neighbour structure supplied as weights.

Why a disk is not a circle

A disk of hexagons is itself a rough hexagon with a stepped edge. Its inner radius is set by the notches between outer cells and its outer radius by their corners, so the ratio is large for small k โ€” 1.447 at k=1 โ€” and shrinks towards the shape of a hexagon as k grows: 1.328 at k=3, 1.293 at k=6.

On top of that, a point is not at its cell's centre, so the disk around the point's cell is offset from the point by up to about one edge length. Both effects are why a disk buffer needs a margin of extra rings and a true-distance refinement.

Triage table of five surprising behaviours of H3 neighbour functions and the response to each.
Near a pentagon, 360 of 1,600 grid_distance calls raised in one measured neighbourhood.

Edge cases or notes

  • Smoothing a dictionary of occupied cells never fills gaps. Build the study area with a polygon fill first, zeros included.
  • Disks cross the antimeridian without special handling. A res-7 disk near Fiji returned its seven cells with centres on both sides of ยฑ180ยฐ; only drawing them needs care.
  • Cost grows with the square of k. A k=5 disk is 91 cells; 100,000 of them took 1.93 s against 0.27 s at k=1.
  • The total drifts at the edges. 42,577 became 42,497 averaging over existing neighbours, and 41,307 when outside cells counted as zero.
  • Centre weighting changes little. Doubling the centre weight kept the maximum at 16.38 against 15.14 unweighted.
  • All cells must share one resolution. Neighbour functions work within a resolution; mixed-resolution sets need uncompacting first.
  • Smoothed surfaces are for display. Run spatial statistics on the raw counts.

FAQ

What is the difference between a disk and a ring?

grid_disk returns every cell within k steps, 1 + 3k(k + 1) cells including the origin. grid_ring returns only the cells exactly k steps away, 6k of them.

Why does H3 grid distance raise H3FailedError?

It works in local grid coordinates that cannot be unfolded across pentagons or over long distances. London to New York at res 5 raised, and so did 360 of 1,600 pairs near one pentagon. Fall back to great-circle distance.

Should I smooth with k=1 or k=2?

k=1 cut empty UK cells from 54.1% to 16.1% while keeping the peak at 15.14; k=2 reached 6.1% empty but flattened the peak to 10.95. Start with k=1 and move up only if the map is still speckled.

Can a k-ring disk replace a buffer?

Only as a candidate filter. A k=1 disk at res 8 reached 969 m one way and 1,402 m the other, so refine the candidates by true distance.

Is a smoothed H3 surface safe for statistics?

No. Smoothing made neighbouring UK cells correlate at 0.892 against 0.406 raw, entirely by construction. Use the raw counts with explicit neighbour weights.